Merge branch 'develop' of https://codeberg.org/calckey/calckey into timelines

This commit is contained in:
freeplay 2023-07-17 19:09:39 -04:00
commit 3ec7cf58d6
196 changed files with 9572 additions and 5264 deletions

View file

@ -121,7 +121,7 @@ redis:
# ┌─────────────────────┐ # ┌─────────────────────┐
#───┘ Other configuration └───────────────────────────────────── #───┘ Other configuration └─────────────────────────────────────
# Maximum length of a post (default 3000, max 250000000) # Maximum length of a post (default 3000, max 100000)
#maxNoteLength: 3000 #maxNoteLength: 3000
# Maximum length of an image caption (default 1500, max 8192) # Maximum length of an image caption (default 1500, max 8192)

3
.gitignore vendored
View file

@ -27,7 +27,7 @@ coverage
!/.config/helm_values_example.yml !/.config/helm_values_example.yml
!/.config/LICENSE !/.config/LICENSE
#docker dev config # docker dev config
/dev/docker-compose.yml /dev/docker-compose.yml
# misskey # misskey
@ -46,6 +46,7 @@ files
ormconfig.json ormconfig.json
packages/backend/assets/instance.css packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3 packages/backend/assets/sounds/None.mp3
packages/backend/assets/LICENSE
!packages/backend/src/db !packages/backend/src/db

View file

@ -6,23 +6,16 @@
## Planned ## Planned
- Stucture - Stucture
- [DragonflyDB](https://dragonflydb.io/) support as a Redis alternative
- Optionally use [ScyllaDB](https://www.scylladb.com/open-source-nosql-database/) for storing notes
- Rewrite backend in Rust and [Rocket](https://rocket.rs/) - Rewrite backend in Rust and [Rocket](https://rocket.rs/)
- Use [Magic RegExP](https://regexp.dev/) for RegEx 🦄
- Function - Function
- User "choices" (recommended users) and featured hashtags like Mastodon and Soapbox - User "choices" (recommended users) and featured hashtags like Mastodon and Soapbox
- Join Reason system like Mastodon/Pleroma - Join Reason system like Mastodon/Pleroma
- Option to publicize server blocks - Option to publicize server blocks
- More antenna options - More antenna options
- Groups - Groups
- Form
- Lookup/details for post/file/server
- [Rat mode?](https://stop.voring.me/notes/933fx97bmd)
## Work in progress ## Work in progress
- Link verification
- Better Messaging UI - Better Messaging UI
- Better API Documentation - Better API Documentation
- Remote follow button - Remote follow button
@ -30,6 +23,7 @@
- Timeline filters - Timeline filters
- Events - Events
- Fully revamp non-logged-in screen - Fully revamp non-logged-in screen
- Optionally use [ScyllaDB](https://www.scylladb.com/open-source-nosql-database/) for storing notes
## Implemented ## Implemented
@ -122,6 +116,9 @@
- Let moderators see moderation nodes - Let moderators see moderation nodes
- Non-mangled unicode emojis - Non-mangled unicode emojis
- Skin tone selection support - Skin tone selection support
- [DragonflyDB](https://dragonflydb.io/) support as a Redis alternative
- Link verification
- Importing posts from other Calckey/Misskey/Mastodon/Akkoma/Pleroma instances
## Implemented (remote) ## Implemented (remote)

View file

@ -1179,7 +1179,6 @@ _profile:
youCanIncludeHashtags: "يمكنك أيضًا إضافة وسوم إلى سيرتك التعريفية." youCanIncludeHashtags: "يمكنك أيضًا إضافة وسوم إلى سيرتك التعريفية."
metadata: "معلومات إضافية" metadata: "معلومات إضافية"
metadataEdit: "عدّل المعلومات الإضافية" metadataEdit: "عدّل المعلومات الإضافية"
metadataDescription: "يُمكنك عرض 4 حقول معلومات في ملفك الشخصي"
metadataLabel: "التسمية" metadataLabel: "التسمية"
metadataContent: "المحتوى" metadataContent: "المحتوى"
changeAvatar: "غيّر الصورة الرمزية" changeAvatar: "غيّر الصورة الرمزية"

View file

@ -1268,7 +1268,7 @@ _profile:
youCanIncludeHashtags: "হ্যাশট্যাগ অন্তর্ভুক্ত করা যেতে পারে।" youCanIncludeHashtags: "হ্যাশট্যাগ অন্তর্ভুক্ত করা যেতে পারে।"
metadata: "অতিরিক্ত তথ্য" metadata: "অতিরিক্ত তথ্য"
metadataEdit: "অতিরিক্ত তথ্য সম্পাদনা করুন" metadataEdit: "অতিরিক্ত তথ্য সম্পাদনা করুন"
metadataDescription: "আপনি আপনার প্রোফাইলে একটি টেবিল হিসাবে চারটি অতিরিক্ত তথ্য দেখাতে পারেন।" metadataDescription: "আপনি আপনার প্রোফাইলে একটি টেবিল হিসাবে চারটি অতিরিক্ত তথ্য দেখাতে পারেন।. আপনি আপনার প্রোফাইলে লিঙ্কটি যাচাই করতে {rel} এর সাথে একটি {a} ট্যাগ বা {l} ট্যাগ যোগ করতে পারেন!"
metadataLabel: "লেবেল" metadataLabel: "লেবেল"
metadataContent: "বিষয়বস্তু" metadataContent: "বিষয়বস্তু"
changeAvatar: "অ্যাভাটার পরিবর্তন করুন" changeAvatar: "অ্যাভাটার পরিবর্তন করুন"

1
locales/bul_BG.yml Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -409,8 +409,9 @@ _profile:
locationDescription: Si primer introduïu la vostra ciutat, es mostrarà l'hora local locationDescription: Si primer introduïu la vostra ciutat, es mostrarà l'hora local
a altres usuaris. a altres usuaris.
name: Nom name: Nom
metadataDescription: Fent servir això, podràs mostrar camps d'informació addicionals metadataDescription: "Fent servir això, podràs mostrar camps d'informació addicionals
al vostre perfil. al vostre perfil. Podeu afegir una etiqueta {a} o una etiqueta {l} amb {rel} per
verificar l'enllaç al vostre perfil!"
_exportOrImport: _exportOrImport:
followingList: "Usuaris que segueixes" followingList: "Usuaris que segueixes"
muteList: "Silencia" muteList: "Silencia"
@ -1609,6 +1610,7 @@ _aboutMisskey:
pleaseDonateToHost: Penseu també en fer una donació a la vostre instància, {host}, pleaseDonateToHost: Penseu també en fer una donació a la vostre instància, {host},
per ajudar-lo a suportar els costos de funcionament. per ajudar-lo a suportar els costos de funcionament.
donateHost: Fes una donació a {host} donateHost: Fes una donació a {host}
sponsors: Patrocinadors de Calckey
unknown: Desconegut unknown: Desconegut
pageLikesCount: Nombre de pàgines amb M'agrada pageLikesCount: Nombre de pàgines amb M'agrada
youAreRunningUpToDateClient: Estás fent servir la versió del client més nova. youAreRunningUpToDateClient: Estás fent servir la versió del client més nova.
@ -2160,3 +2162,4 @@ remindMeLater: Potser després
removeMember: Elimina el membre removeMember: Elimina el membre
removeQuote: Elimina la cita removeQuote: Elimina la cita
removeRecipient: Elimina el destinatari removeRecipient: Elimina el destinatari
verifiedLink: Enllaç verificat

View file

@ -1551,7 +1551,7 @@ _profile:
metadata: "Zusätzliche Informationen" metadata: "Zusätzliche Informationen"
metadataEdit: "Zusätzliche Informationen bearbeiten" metadataEdit: "Zusätzliche Informationen bearbeiten"
metadataDescription: "Hierdurch kannst du auf deinem Profil zusätzliche Informationsblöcke metadataDescription: "Hierdurch kannst du auf deinem Profil zusätzliche Informationsblöcke
anzeigen lassen." anzeigen lassen. Sie können ein {a}-Tag oder ein {l}-Tag mit {rel} hinzufügen, um den Link in Ihrem Profil zu überprüfen!"
metadataLabel: "Beschriftung" metadataLabel: "Beschriftung"
metadataContent: "Inhalt" metadataContent: "Inhalt"
changeAvatar: "Profilbild ändern" changeAvatar: "Profilbild ändern"

View file

@ -177,7 +177,7 @@ flagShowTimelineRepliesDescription: "Shows replies of users to posts of other us
autoAcceptFollowed: "Automatically approve follow requests from users you're following" autoAcceptFollowed: "Automatically approve follow requests from users you're following"
addAccount: "Add account" addAccount: "Add account"
loginFailed: "Failed to sign in" loginFailed: "Failed to sign in"
showOnRemote: "View on remote server" showOnRemote: "Open original page"
general: "General" general: "General"
accountMoved: "User has moved to a new account:" accountMoved: "User has moved to a new account:"
wallpaper: "Wallpaper" wallpaper: "Wallpaper"
@ -1124,6 +1124,7 @@ remindMeLater: "Maybe later"
removeQuote: "Remove quote" removeQuote: "Remove quote"
removeRecipient: "Remove recipient" removeRecipient: "Remove recipient"
removeMember: "Remove member" removeMember: "Remove member"
verifiedLink: "Verified link"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing description: "Reduces the effort of server moderation through automatically recognizing
@ -1676,8 +1677,10 @@ _profile:
youCanIncludeHashtags: "You can also include hashtags in your bio." youCanIncludeHashtags: "You can also include hashtags in your bio."
metadata: "Additional Information" metadata: "Additional Information"
metadataEdit: "Edit additional Information" metadataEdit: "Edit additional Information"
metadataDescription: "Using these, you can display additional information fields metadataDescription:
in your profile." "Using these, you can display additional information fields
in your profile. You can add an {a} tag or {l} tag with {rel}
to verify the link on your profile!"
metadataLabel: "Label" metadataLabel: "Label"
metadataContent: "Content" metadataContent: "Content"
changeAvatar: "Change avatar" changeAvatar: "Change avatar"

View file

@ -642,7 +642,7 @@ wordMute: "Silenciar palabras"
regexpError: "Error de la expresión regular" regexpError: "Error de la expresión regular"
regexpErrorDescription: "Ocurrió un error en la expresión regular en la linea {line} regexpErrorDescription: "Ocurrió un error en la expresión regular en la linea {line}
de las palabras muteadas {tab}" de las palabras muteadas {tab}"
instanceMute: "Instancias silenciadas" instanceMute: "Servidores silenciados"
userSaysSomething: "{name} dijo algo" userSaysSomething: "{name} dijo algo"
makeActive: "Activar" makeActive: "Activar"
display: "Apariencia" display: "Apariencia"
@ -671,14 +671,14 @@ sample: "Muestra"
abuseReports: "Reportes" abuseReports: "Reportes"
reportAbuse: "Reportar" reportAbuse: "Reportar"
reportAbuseOf: "Reportar a {name}" reportAbuseOf: "Reportar a {name}"
fillAbuseReportDescription: "Ingrese los detalles del reporte. Si hay una nota en fillAbuseReportDescription: "Ingrese los detalles del reporte. Si hay una publicación
particular, ingrese la URL de esta." en particular, ingrese la URL de esta."
abuseReported: "Se ha enviado el reporte. Muchas gracias." abuseReported: "Se ha enviado el reporte. Muchas gracias."
reporter: "Reportador" reporter: "Reportador"
reporteeOrigin: "Reportar a" reporteeOrigin: "Reportar a"
reporterOrigin: "Origen del reporte" reporterOrigin: "Origen del reporte"
forwardReport: "Transferir un informe a una instancia remota" forwardReport: "Transferir reporte a un servidor remoto"
forwardReportIsAnonymous: "No puede ver su información de la instancia remota y aparecerá forwardReportIsAnonymous: "No puede ver su información del servidor remoto y aparecerá
como una cuenta anónima del sistema" como una cuenta anónima del sistema"
send: "Enviar" send: "Enviar"
abuseMarkAsResolved: "Marcar reporte como resuelto" abuseMarkAsResolved: "Marcar reporte como resuelto"
@ -686,7 +686,7 @@ openInNewTab: "Abrir en una Nueva Pestaña"
openInSideView: "Abrir en una vista al costado" openInSideView: "Abrir en una vista al costado"
defaultNavigationBehaviour: "Navegación por defecto" defaultNavigationBehaviour: "Navegación por defecto"
editTheseSettingsMayBreakAccount: "Editar estas configuraciones puede dañar su cuenta." editTheseSettingsMayBreakAccount: "Editar estas configuraciones puede dañar su cuenta."
instanceTicker: "Información de notas de la instancia" instanceTicker: "Información de publicaciones de el servidor"
waitingFor: "Esperando a {x}" waitingFor: "Esperando a {x}"
random: "Aleatorio" random: "Aleatorio"
system: "Sistema" system: "Sistema"
@ -697,14 +697,14 @@ createNew: "Crear"
optional: "Opcional" optional: "Opcional"
createNewClip: "Crear clip nuevo" createNewClip: "Crear clip nuevo"
unclip: "Quitar clip" unclip: "Quitar clip"
confirmToUnclipAlreadyClippedNote: "Esta nota ya está incluida en el clip \"{name}\"\ confirmToUnclipAlreadyClippedNote: "Esta publicación ya está incluida en el clip \"\
. ¿Quiere quitar la nota del clip?" {name}\". ¿Quiere quitar la nota del clip?"
public: "Público" public: "Público"
i18nInfo: "Calckey está siendo traducido a varios idiomas gracias a voluntarios. Se i18nInfo: "Calckey está siendo traducido a varios idiomas gracias a voluntarios. Se
puede colaborar traduciendo en {link}" puede colaborar traduciendo en {link}"
manageAccessTokens: "Administrar tokens de acceso" manageAccessTokens: "Administrar tokens de acceso"
accountInfo: "Información de la Cuenta" accountInfo: "Información de la Cuenta"
notesCount: "Cantidad de notas" notesCount: "Cantidad de publicaciones"
repliesCount: "Cantidad de respuestas hechas" repliesCount: "Cantidad de respuestas hechas"
renotesCount: "Cantidad de renotas hechas" renotesCount: "Cantidad de renotas hechas"
repliedCount: "Cantidad de respuestas recibidas" repliedCount: "Cantidad de respuestas recibidas"
@ -720,7 +720,7 @@ no: "No"
driveFilesCount: "Cantidad de archivos en el drive" driveFilesCount: "Cantidad de archivos en el drive"
driveUsage: "Uso del drive" driveUsage: "Uso del drive"
noCrawle: "Rechazar indexación del crawler" noCrawle: "Rechazar indexación del crawler"
noCrawleDescription: "Pedir a los motores de búsqueda que no indexen tu perfil, notas, noCrawleDescription: "Pedir a los motores de búsqueda que no indexen tu perfil, publicaciones,
páginas, etc." páginas, etc."
lockedAccountInfo: "A menos que configures la visibilidad de tus notas como \"Sólo lockedAccountInfo: "A menos que configures la visibilidad de tus notas como \"Sólo
seguidores\", tus notas serán visibles para cualquiera, incluso si requieres que seguidores\", tus notas serán visibles para cualquiera, incluso si requieres que
@ -734,7 +734,7 @@ verificationEmailSent: "Se le ha enviado un correo electrónico de confirmación
configuración." configuración."
notSet: "Sin especificar" notSet: "Sin especificar"
emailVerified: "Su dirección de correo electrónico ha sido verificada." emailVerified: "Su dirección de correo electrónico ha sido verificada."
noteFavoritesCount: "Número de notas favoritas" noteFavoritesCount: "Número de publicaciones favoritas"
pageLikesCount: "Número de favoritos en la página" pageLikesCount: "Número de favoritos en la página"
pageLikedCount: "Número de favoritos de su página" pageLikedCount: "Número de favoritos de su página"
contact: "Contacto" contact: "Contacto"
@ -975,7 +975,7 @@ shuffle: "Aleatorio"
account: "Cuentas" account: "Cuentas"
move: "Mover" move: "Mover"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "Reduce el esfuerzo de la moderación el el servidor a través del reconocimiento description: "Reduce el esfuerzo de la moderación de el servidor a través del reconocimiento
automático de contenido NSFW usando 'Machine Learning'. Esto puede incrementar automático de contenido NSFW usando 'Machine Learning'. Esto puede incrementar
ligeramente la carga en el servidor." ligeramente la carga en el servidor."
sensitivity: "Sensibilidad de detección" sensitivity: "Sensibilidad de detección"
@ -1295,7 +1295,7 @@ _time:
_tutorial: _tutorial:
title: "Cómo usar Calckey" title: "Cómo usar Calckey"
step1_1: "¡Bienvenido!" step1_1: "¡Bienvenido!"
step1_2: "Vamos a configurarte. Estarás listo y funcionando en poco tiempo" step1_2: "Vamos a configurarte. ¡Estarás listo y funcionando en poco tiempo!"
step2_1: "En primer lugar, rellena tu perfil" step2_1: "En primer lugar, rellena tu perfil"
step2_2: "Proporcionar algo de información sobre quién eres hará que sea más fácil step2_2: "Proporcionar algo de información sobre quién eres hará que sea más fácil
para los demás saber si quieren ver tus notas o seguirte." para los demás saber si quieren ver tus notas o seguirte."
@ -1475,7 +1475,7 @@ _profile:
youCanIncludeHashtags: "Puedes añadir hashtags" youCanIncludeHashtags: "Puedes añadir hashtags"
metadata: "información adicional" metadata: "información adicional"
metadataEdit: "Editar información adicional" metadataEdit: "Editar información adicional"
metadataDescription: "Muestra la información adicional en el perfil" metadataDescription: "Muestra la información adicional en el perfil. ¡Puede agregar una etiqueta {a} o una etiqueta {l} con {rel} para verificar el enlace en su perfil!"
metadataLabel: "Etiqueta" metadataLabel: "Etiqueta"
metadataContent: "Contenido" metadataContent: "Contenido"
changeAvatar: "Cambiar avatar" changeAvatar: "Cambiar avatar"
@ -1789,7 +1789,7 @@ _pages:
splitStrByLine: "Separar texto en lineas" splitStrByLine: "Separar texto en lineas"
_splitStrByLine: _splitStrByLine:
arg1: "Texto" arg1: "Texto"
ref: "Variables" ref: "Variable"
aiScriptVar: "Variable de AiScript" aiScriptVar: "Variable de AiScript"
fn: "funciones" fn: "funciones"
_fn: _fn:
@ -1800,8 +1800,8 @@ _pages:
_for: _for:
arg1: "Cantidad de repeticiones" arg1: "Cantidad de repeticiones"
arg2: "Acción" arg2: "Acción"
typeError: "El slot {slot} acepta el tipo {expect} pero fue ingresado el tipo typeError: "El slot {slot} acepta el tipo \"{expect}\" pero fue ingresado el tipo
{actual}" \"{actual}\""
thereIsEmptySlot: "El slot {slot} está vacío" thereIsEmptySlot: "El slot {slot} está vacío"
types: types:
string: "Texto" string: "Texto"

View file

@ -1413,7 +1413,7 @@ _profile:
metadata: "Informations supplémentaires" metadata: "Informations supplémentaires"
metadataEdit: "Éditer les informations supplémentaires" metadataEdit: "Éditer les informations supplémentaires"
metadataDescription: "Vous pouvez afficher jusqu'à quatre informations supplémentaires metadataDescription: "Vous pouvez afficher jusqu'à quatre informations supplémentaires
dans votre profil." dans votre profil. Vous pouvez ajouter une balise {a} ou une balise {l} avec {rel} pour vérifier le lien sur votre profil!"
metadataLabel: "Étiquette" metadataLabel: "Étiquette"
metadataContent: "Contenu" metadataContent: "Contenu"
changeAvatar: "Changer l'image de profil" changeAvatar: "Changer l'image de profil"

View file

@ -1399,7 +1399,7 @@ _profile:
metadata: "Informasi tambahan" metadata: "Informasi tambahan"
metadataEdit: "Sunting informasi tambahan" metadataEdit: "Sunting informasi tambahan"
metadataDescription: "Kamu dapat menampilkan hingga 4 bagian informasi tambahan\ metadataDescription: "Kamu dapat menampilkan hingga 4 bagian informasi tambahan\
\ ke dalam profilmu." \ ke dalam profilmu. Anda dapat menambahkan tag {a} atau tag {l} dengan {rel} untuk memverifikasi tautan di profil Anda!"
metadataLabel: "Label" metadataLabel: "Label"
metadataContent: "Isi" metadataContent: "Isi"
changeAvatar: "Ubah avatar" changeAvatar: "Ubah avatar"

View file

@ -1266,7 +1266,7 @@ _profile:
metadata: "Informazioni aggiuntive" metadata: "Informazioni aggiuntive"
metadataEdit: "Modifica informazioni aggiuntive" metadataEdit: "Modifica informazioni aggiuntive"
metadataDescription: "Puoi pubblicare fino a quattro informazioni aggiuntive sul metadataDescription: "Puoi pubblicare fino a quattro informazioni aggiuntive sul
profilo." profilo. Puoi aggiungere un tag {a} o {l} con {rel} per verificare il link sul tuo profilo!"
metadataLabel: "Etichetta" metadataLabel: "Etichetta"
metadataContent: "Contenuto" metadataContent: "Contenuto"
changeAvatar: "Modifica immagine profilo" changeAvatar: "Modifica immagine profilo"

View file

@ -1491,7 +1491,7 @@ _profile:
youCanIncludeHashtags: "ハッシュタグを含められます。" youCanIncludeHashtags: "ハッシュタグを含められます。"
metadata: "追加情報" metadata: "追加情報"
metadataEdit: "追加情報を編集" metadataEdit: "追加情報を編集"
metadataDescription: "プロフィールに表として追加情報を表示できます。" metadataDescription: "プロフィールに表として追加情報を表示できます。{a}タグまたは{l}タグを{rel}とともに追加すると、プロフィールのリンクを確認できます。"
metadataLabel: "ラベル" metadataLabel: "ラベル"
metadataContent: "内容" metadataContent: "内容"
changeAvatar: "アバター画像を変更" changeAvatar: "アバター画像を変更"

View file

@ -1319,7 +1319,7 @@ _profile:
youCanIncludeHashtags: "해시 태그를 포함할 수 있습니다." youCanIncludeHashtags: "해시 태그를 포함할 수 있습니다."
metadata: "추가 정보" metadata: "추가 정보"
metadataEdit: "추가 정보 편집" metadataEdit: "추가 정보 편집"
metadataDescription: "프로필에 추가 정보를 표시할 수 있어요" metadataDescription: "프로필에 추가 정보를 표시할 수 있어요. {rel}과 함께 {a} 태그 또는 {l} 태그를 추가하여 프로필의 링크를 확인할 수 있습니다!"
metadataLabel: "라벨" metadataLabel: "라벨"
metadataContent: "내용" metadataContent: "내용"
changeAvatar: "아바타 이미지 변경" changeAvatar: "아바타 이미지 변경"

View file

@ -1404,7 +1404,7 @@ _profile:
metadata: "Dodatkowe informacje" metadata: "Dodatkowe informacje"
metadataEdit: "Edytuj dodatkowe informacje" metadataEdit: "Edytuj dodatkowe informacje"
metadataDescription: "Możesz wyświetlać do czterech sekcji dodatkowych informacji metadataDescription: "Możesz wyświetlać do czterech sekcji dodatkowych informacji
na swoim profilu." na swoim profilu. Możesz dodać tag {a} lub tag {l} z {rel}, aby zweryfikować link w swoim profilu!"
metadataLabel: "Etykieta" metadataLabel: "Etykieta"
metadataContent: "Treść" metadataContent: "Treść"
changeAvatar: "Zmień awatar" changeAvatar: "Zmień awatar"

View file

@ -1398,7 +1398,7 @@ _profile:
youCanIncludeHashtags: "Можете использовать здесь хэштеги." youCanIncludeHashtags: "Можете использовать здесь хэштеги."
metadata: "Дополнительные сведения" metadata: "Дополнительные сведения"
metadataEdit: "Редактировать дополнительные сведения" metadataEdit: "Редактировать дополнительные сведения"
metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль." metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль. Вы можете добавить тег {a} или тег {l} с {rel}, чтобы подтвердить ссылку в своем профиле!"
metadataLabel: "Метка" metadataLabel: "Метка"
metadataContent: "Содержимое" metadataContent: "Содержимое"
changeAvatar: "Поменять аватар" changeAvatar: "Поменять аватар"

View file

@ -1337,7 +1337,7 @@ _profile:
youCanIncludeHashtags: "Vo svojom bio môžete mať aj hashtagy." youCanIncludeHashtags: "Vo svojom bio môžete mať aj hashtagy."
metadata: "Dodatočné informácie" metadata: "Dodatočné informácie"
metadataEdit: "Upraviť dodatočné informácie" metadataEdit: "Upraviť dodatočné informácie"
metadataDescription: "Vo svojom profile môžete uviesť až štyri dodatočné informačné polia." metadataDescription: "Vo svojom profile môžete uviesť až štyri dodatočné informačné polia. Dodate lahko oznako {a} ali oznako {l} z {rel}, da preverite povezavo v svojem profile!"
metadataLabel: "Popisok" metadataLabel: "Popisok"
metadataContent: "Obsah" metadataContent: "Obsah"
changeAvatar: "Zmeniť avatara" changeAvatar: "Zmeniť avatara"

File diff suppressed because it is too large Load diff

View file

@ -153,10 +153,11 @@ flagAsBotDescription: "Ввімкніть якщо цей обліковий з
Ця опція позначить обліковий запис як бота. Це потрібно щоб виключити безкінечну Ця опція позначить обліковий запис як бота. Це потрібно щоб виключити безкінечну
інтеракцію між ботами а також відповідного підлаштування Calckey." інтеракцію між ботами а також відповідного підлаштування Calckey."
flagAsCat: "Акаунт кота" flagAsCat: "Акаунт кота"
flagAsCatDescription: "Ввімкніть, щоб позначити, що обліковий запис є котиком." flagAsCatDescription: "Ввімкніть, щоб позначити, що обліковий запис є котиком, та
flagShowTimelineReplies: "Показувати відповіді на нотатки на часовій шкалі" отримати котячі вуха!"
flagShowTimelineRepliesDescription: "Показує відповіді користувачів на нотатки інших flagShowTimelineReplies: "Показувати відповіді на записи в стрічці"
користувачів на часовій шкалі." flagShowTimelineRepliesDescription: "Показує відповіді користувачів на записи інших
користувачів у стрічці."
autoAcceptFollowed: "Автоматично приймати запити на підписку від користувачів, на autoAcceptFollowed: "Автоматично приймати запити на підписку від користувачів, на
яких ви підписані" яких ви підписані"
addAccount: "Додати акаунт" addAccount: "Додати акаунт"
@ -169,7 +170,7 @@ removeWallpaper: "Прибрати шпалери"
searchWith: "Пошук: {q}" searchWith: "Пошук: {q}"
youHaveNoLists: "У вас немає списків" youHaveNoLists: "У вас немає списків"
followConfirm: "Підписатися на {name}?" followConfirm: "Підписатися на {name}?"
proxyAccount: "Проксі-акаунт" proxyAccount: "Обліковий запис проксі"
proxyAccountDescription: "Обліковий запис проксі це обліковий запис, який діє як proxyAccountDescription: "Обліковий запис проксі це обліковий запис, який діє як
віддалений підписник для користувачів за певних умов. Наприклад, коли користувач віддалений підписник для користувачів за певних умов. Наприклад, коли користувач
додає віддаленого користувача до списку, активність віддаленого користувача не буде додає віддаленого користувача до списку, активність віддаленого користувача не буде
@ -217,7 +218,7 @@ blockedUsers: "Заблоковані користувачі"
noUsers: "Немає користувачів" noUsers: "Немає користувачів"
editProfile: "Редагувати обліковий запис" editProfile: "Редагувати обліковий запис"
noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?" noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?"
pinLimitExceeded: "Більше записів не можна закріпити" pinLimitExceeded: "Ви не можете закріпити більше записів"
intro: "Встановлення Calckey завершено! Будь ласка, створіть обліковий запис адміністратора." intro: "Встановлення Calckey завершено! Будь ласка, створіть обліковий запис адміністратора."
done: "Готово" done: "Готово"
processing: "Обробка" processing: "Обробка"
@ -232,7 +233,7 @@ all: "Всі"
subscribing: "Підписка" subscribing: "Підписка"
publishing: "Публікація" publishing: "Публікація"
notResponding: "Не відповідає" notResponding: "Не відповідає"
instanceFollowing: "Підписка на інстанс" instanceFollowing: "Підписка на сервер"
instanceFollowers: "Підписники серверу" instanceFollowers: "Підписники серверу"
instanceUsers: "Користувачі цього серверу" instanceUsers: "Користувачі цього серверу"
changePassword: "Змінити пароль" changePassword: "Змінити пароль"
@ -359,7 +360,7 @@ pinnedUsersDescription: "Впишіть в список користувачів
\"Знайти\", ім'я в стовпчик." \"Знайти\", ім'я в стовпчик."
pinnedPages: "Закріплені сторінки" pinnedPages: "Закріплені сторінки"
pinnedPagesDescription: "Введіть шляхи сторінок, які ви бажаєте закріпити на головній pinnedPagesDescription: "Введіть шляхи сторінок, які ви бажаєте закріпити на головній
сторінці цього інстанса, розділені новими рядками." сторінці цього сервера, розділені новими рядками."
pinnedClipId: "Ідентифікатор закріпленої замітки" pinnedClipId: "Ідентифікатор закріпленої замітки"
pinnedNotes: "Закріплений запис" pinnedNotes: "Закріплений запис"
hcaptcha: "hCaptcha" hcaptcha: "hCaptcha"
@ -506,12 +507,14 @@ promote: "Виділити"
numberOfDays: "Кількість днів" numberOfDays: "Кількість днів"
hideThisNote: "Сховати цей запис" hideThisNote: "Сховати цей запис"
showFeaturedNotesInTimeline: "Показувати популярні записи у стрічці" showFeaturedNotesInTimeline: "Показувати популярні записи у стрічці"
objectStorage: "Object Storage" objectStorage: "Сховище"
useObjectStorage: "Використовувати object storage" useObjectStorage: "Використовувати object storage"
objectStorageBaseUrl: "Base URL" objectStorageBaseUrl: "Базовий URL"
objectStorageBaseUrlDesc: "Це початкова частина адреси, що використовується CDN або objectStorageBaseUrlDesc: "URL-адреса, що використовується як джерело. Вкажіть URL-адресу
проксі, наприклад для S3: https://<bucket>.s3.amazonaws.com, або GCS: 'https://storage.googleapis.com/<bucket>'" вашого CDN або проксі-сервера, якщо ви їх використовуєте.\nДля S3 використовуйте
objectStorageBucket: "Bucket" 'https://<bucket>.s3.amazonaws.com', а для GCS або подібних сервісів - 'https://storage.googleapis.com/<bucket>',
тощо."
objectStorageBucket: "Сховище (Bucket)"
objectStorageBucketDesc: "Будь ласка вкажіть назву відра в налаштованому сервісі." objectStorageBucketDesc: "Будь ласка вкажіть назву відра в налаштованому сервісі."
objectStoragePrefix: "Prefix" objectStoragePrefix: "Prefix"
objectStoragePrefixDesc: "Файли будуть зберігатись у розташуванні з цим префіксом." objectStoragePrefixDesc: "Файли будуть зберігатись у розташуванні з цим префіксом."
@ -665,11 +668,11 @@ reportAbuse: "Поскаржитись"
reportAbuseOf: "Поскаржитись на {name}" reportAbuseOf: "Поскаржитись на {name}"
fillAbuseReportDescription: "Будь ласка вкажіть подробиці скарги. Якщо скарга стосується fillAbuseReportDescription: "Будь ласка вкажіть подробиці скарги. Якщо скарга стосується
запису, вкажіть посилання на нього." запису, вкажіть посилання на нього."
abuseReported: "Дякуємо, вашу скаргу було відправлено. " abuseReported: "Дякуємо. Ваш звіт було відправлено."
reporter: "Репортер" reporter: "Репортер"
reporteeOrigin: "Про кого повідомлено" reporteeOrigin: "Про кого повідомлено"
reporterOrigin: "Хто повідомив" reporterOrigin: "Хто повідомив"
forwardReport: "Переслати звіт на віддалений інстанс" forwardReport: "Переслати звіт на віддалений сервер"
forwardReportIsAnonymous: "Замість вашого облікового запису, анонімний системний обліковий forwardReportIsAnonymous: "Замість вашого облікового запису, анонімний системний обліковий
запис буде відображатися як доповідач на віддаленому сервері." запис буде відображатися як доповідач на віддаленому сервері."
send: "Відправити" send: "Відправити"
@ -679,16 +682,16 @@ openInSideView: "Відкрити збоку"
defaultNavigationBehaviour: "Поведінка навігації за замовчуванням" defaultNavigationBehaviour: "Поведінка навігації за замовчуванням"
editTheseSettingsMayBreakAccount: "Зміна цих параметрів може призвести до пошкодження editTheseSettingsMayBreakAccount: "Зміна цих параметрів може призвести до пошкодження
вашого акаунта." вашого акаунта."
instanceTicker: "Мітка з назвою інстанса в нотатках" instanceTicker: "Інформація про записи на сервері"
waitingFor: "Чекаємо на {x}" waitingFor: "Чекаємо на {x}"
random: "Випадковий" random: "Випадковий"
system: "Система" system: "Система"
switchUi: "Інтерфейс" switchUi: "Інтерфейс"
desktop: "Десктоп" desktop: "Десктоп"
clip: "Добірка" clip: "Підбірка"
createNew: "Створити новий" createNew: "Створити новий"
optional: "Необов'язково" optional: "Необов'язково"
createNewClip: "Створити нотатку" createNewClip: "Створити підбірку"
public: "Публічний" public: "Публічний"
i18nInfo: "Calckey перекладається на різні мови волонтерами. Ви можете допомогти за i18nInfo: "Calckey перекладається на різні мови волонтерами. Ви можете допомогти за
посиланням: {link}." посиланням: {link}."
@ -793,24 +796,33 @@ hide: "Сховати"
searchByGoogle: "Пошук" searchByGoogle: "Пошук"
indefinitely: "Ніколи" indefinitely: "Ніколи"
file: "Файли" file: "Файли"
reverse: "Перевернути" reverse: "Переворот"
colored: "Кольоровий" colored: "Кольоровий"
label: "Назва" label: "Назва"
localOnly: "Локально" localOnly: "Локально"
_ffVisibility: _ffVisibility:
public: "Опублікувати" public: "Опублікувати"
private: Приватні
followers: Доступно тільки для підписників
_ad: _ad:
back: "Назад" back: "Назад"
reduceFrequencyOfThisAd: Менше показувати цю рекламу
_gallery: _gallery:
unlike: "Не вподобати" unlike: "Не вподобати"
liked: Вподобані записи
like: Подобається
my: Моя галерея
_email: _email:
_follow: _follow:
title: "Новий підписник" title: "Новий підписник"
_receiveFollowRequest:
title: Ви отримали запит на підписку
_registry: _registry:
key: "Ключ" key: "Ключ"
keys: "Ключі" keys: "Ключі"
domain: "Домен" domain: "Домен"
createKey: "Створити ключ" createKey: "Створити ключ"
scope: Область
_aboutMisskey: _aboutMisskey:
about: "Misskey - це програмне забезпечення з відкритим кодом, яке розробляє syuilo about: "Misskey - це програмне забезпечення з відкритим кодом, яке розробляє syuilo
з 2014 року." з 2014 року."
@ -822,12 +834,20 @@ _aboutMisskey:
morePatrons: "Ми дуже цінуємо підтримку багатьох інших помічників, не перелічених morePatrons: "Ми дуже цінуємо підтримку багатьох інших помічників, не перелічених
тут. Дякуємо! 🥰" тут. Дякуємо! 🥰"
patrons: "Підтримали" patrons: "Підтримали"
patronsList: Перераховані в хронологічному порядку, а не за розміром пожертви. Зробіть
внесок за посиланням вище, щоб ваше ім'я було тут!
donateTitle: Сподобався Calckey?
pleaseDonateToCalckey: Будь ласка, підтримайте розробку Calckey.
pleaseDonateToHost: Також не забудьте підтримати ваш домашній сервер {host}, щоб
допомогти з його операційними витратами.
donateHost: Зробити внесок на рахунок {host}
sponsors: Спонсори Calckey
_nsfw: _nsfw:
respect: "Приховувати NSFW медіа" respect: "Приховувати NSFW медіа"
ignore: "Не приховувати NSFW медіа" ignore: "Не приховувати NSFW медіа"
force: "Приховувати всі медіа файли" force: "Приховувати всі медіа файли"
_mfm: _mfm:
cheatSheet: " Довідка MFM" cheatSheet: "Довідка MFM"
intro: "MFM це ексклюзивна мова розмітки тексту в Calckey, яку можна використовувати intro: "MFM це ексклюзивна мова розмітки тексту в Calckey, яку можна використовувати
в багатьох місцях. Тут ви можете переглянути приклади її синтаксису." в багатьох місцях. Тут ви можете переглянути приклади її синтаксису."
dummy: "Calckey розширює світ Федіверсу" dummy: "Calckey розширює світ Федіверсу"
@ -839,35 +859,36 @@ _mfm:
url: "URL" url: "URL"
urlDescription: "Відображаються URL-адреси." urlDescription: "Відображаються URL-адреси."
link: "Посилання" link: "Посилання"
linkDescription: "Окремі частини тексту можуть містити посилання" linkDescription: "Окремі частини тексту можуть містити посилання."
bold: "Жирний шрифт" bold: "Жирний шрифт"
boldDescription: "Виділяє літери, роблячи їх товще" boldDescription: "Виділяє літери, роблячи їх товщими."
small: "Дрібний шрифт" small: "Дрібний шрифт"
smallDescription: "Робить текст маленьким і тонким" smallDescription: "Робить текст маленьким і тонким."
center: "По центру" center: "По центру"
centerDescription: "Показує вміст у центрі" centerDescription: "Показує вміст у центрі."
inlineCode: "Код (у рядку)" inlineCode: "Код (у рядку)"
inlineCodeDescription: "Показує фрагмент тексту у рядку як програмний код" inlineCodeDescription: "Відображає підсвічування синтаксису для коду (програми)."
blockCode: "Код (блок)" blockCode: "Код (блок)"
blockCodeDescription: "Показує кілька рядків тексту як блок програмного кода" blockCodeDescription: "Відображає підсвічування синтаксису для багаторядкового (програмного)
коду в блоці."
inlineMath: "Формула (у рядку)" inlineMath: "Формула (у рядку)"
inlineMathDescription: "Відображення математичних формул (KaTeX) у рядку" inlineMathDescription: "Відображення математичних формул (KaTeX) у рядку"
blockMath: "Формули (блок)" blockMath: "Формули (блок)"
blockMathDescription: "Відображати багаторядкові формули (KaTeX) блоками" blockMathDescription: "Відображати математичні формули (KaTeX) блоками"
quote: "Цитата" quote: "Цитата"
quoteDescription: "Відображає зміст як цитату." quoteDescription: "Відображає зміст як цитату."
emoji: "Кастомні емоджі" emoji: "Кастомні емоджі"
emojiDescription: "Щоб показати нетиповий емоджі, потрібно ввести його назву в двокрапках." emojiDescription: "Щоб показати нетиповий емоджі, потрібно ввести його назву в двокрапках."
search: "Пошук" search: "Пошук"
searchDescription: "Відображає вікно пошуку з попередньо введеним текстом" searchDescription: "Відображає вікно пошуку з попередньо введеним текстом."
flip: "Перевернути" flip: "Перевернути"
flipDescription: "Віддзеркалює вміст по горизонталі або вертикалі" flipDescription: "Віддзеркалює вміст по горизонталі або вертикалі."
jelly: "Анімація (желе)" jelly: "Анімація (желе)"
jellyDescription: "Створює желеподібну анімацію" jellyDescription: "Створює желеподібну анімацію."
tada: "Анімація (Тада!)" tada: "Анімація (Тада!)"
tadaDescription: "Створює анімацію з відчуттям \"Тада!\"" tadaDescription: "Створює анімацію з відчуттям \"Тада!\"."
jump: "Анімація (стрибки)" jump: "Анімація (стрибки)"
jumpDescription: "Показує стрибаючу анімацію" jumpDescription: "Надає вмісту стрибучу анімацію."
bounce: "Анімація (пружина)" bounce: "Анімація (пружина)"
shake: "Анімація (Shake)" shake: "Анімація (Shake)"
twitch: "Анімація (Twitch)" twitch: "Анімація (Twitch)"
@ -884,6 +905,36 @@ _mfm:
font: "Шрифт" font: "Шрифт"
fontDescription: "Встановлює шрифт для контенту." fontDescription: "Встановлює шрифт для контенту."
rotate: "Обертати" rotate: "Обертати"
play: Відтворити MFM
alwaysPlay: Завжди автозапускати всі анімовані MFM
twitchDescription: Надає контенту анімацію, що сильно сіпається.
spinDescription: Надає контенту анімацію обертання.
sparkle: Блиск
sparkleDescription: Надає вмісту ефект мерехтливого блиску.
fade: Згасання
fadeDescription: Зменшує та збільшує видимість контенту.
crop: Обрізати
cropDescription: Обрізати вміст.
scale: Масштабувати
positionDescription: Перемістити вміст на вказане значення.
scaleDescription: Масштабувати вміст на вказану величину.
background: Фоновий колір
foreground: Колір переднього плану
foregroundDescription: Змінити колір тексту на передньому плані.
bounceDescription: Надає контенту пружної анімації.
shakeDescription: Надає контенту тремтливої анімації.
rainbowDescription: Робить вміст веселковим.
rotateDescription: Повертає вміст на вказаний кут.
advancedDescription: Якщо вимкнено, дозволяє лише базову розмітку, якщо не відтворюється
анімований MFM
plainDescription: Вимикає ефекти всіх MFM, що містяться в цьому MFM-ефекті.
stop: Зупинити MFM
plain: Звичайний текст
advanced: Розширені MFM
warn: MFM може містити швидко-рухому або яскраву анімацію
position: Розташування
rainbow: Веселка
backgroundDescription: Змінити колір фону тексту.
_instanceTicker: _instanceTicker:
none: "Не відображати" none: "Не відображати"
remote: "Відображати для віддалених користувачів" remote: "Відображати для віддалених користувачів"
@ -892,6 +943,7 @@ _serverDisconnectedBehavior:
reload: "Автоматично перезавантажити" reload: "Автоматично перезавантажити"
dialog: "Показати діалогове вікно" dialog: "Показати діалогове вікно"
quiet: "Показати ненав’язливе попередження" quiet: "Показати ненав’язливе попередження"
nothing: Нічого не робити
_channel: _channel:
create: "Створити канал" create: "Створити канал"
edit: "Редагувати канал" edit: "Редагувати канал"
@ -900,22 +952,27 @@ _channel:
featured: "Тренди" featured: "Тренди"
following: "Підписки" following: "Підписки"
usersCount: "{n} учасників" usersCount: "{n} учасників"
notesCount: "{n} дописів" notesCount: "{n} записів"
nameOnly: Тільки назва
nameAndDescription: Назва та опис
owned: Власні
_menuDisplay: _menuDisplay:
hide: "Сховати" hide: "Сховати"
sideFull: Збоку
sideIcon: Збоку (тільки іконки)
top: Верх
_wordMute: _wordMute:
muteWords: "Заглушені слова" muteWords: "Заглушені слова"
muteWordsDescription: "Розділення ключових слів пробілами для \"І\" або з нової muteWordsDescription: "Відокремліть ключові слова пробілами для умови \"І\" або
лінійки для \"АБО\"" з нового рядку для умови \"АБО\"."
muteWordsDescription2: "Для використання RegEx, ключові слова потрібно вписати поміж muteWordsDescription2: "Для використання RegEx, ключові слова потрібно вписати поміж
слешів \"/\"." слешів \"/\"."
softDescription: "Приховати записи які відповідають критеріям зі стрічки подій." softDescription: "Приховати записи які відповідають критеріям зі стрічки."
hardDescription: "Приховати записи які відповідають критеріям зі стрічки подій. hardDescription: "Приховати записи які відповідають критеріям зі стрічки подій.
Також приховані записи не будуть додані до стрічки подій навіть якщо критерії Також приховані записи не будуть додані до стрічки навіть якщо критерії буде змінено."
буде змінено."
soft: "М'яко" soft: "М'яко"
hard: "Жорстко" hard: "Жорстко"
mutedNotes: "Заблоковані нотатки" mutedNotes: "Ігноровані записи"
_theme: _theme:
explore: "Оглянути теми" explore: "Оглянути теми"
install: "Встановити тему" install: "Встановити тему"
@ -979,9 +1036,20 @@ _theme:
accentDarken: "Акцент (Затемлений)" accentDarken: "Акцент (Затемлений)"
accentLighten: "Акцент (Освітлений)" accentLighten: "Акцент (Освітлений)"
fgHighlighted: "Виділений текст" fgHighlighted: "Виділений текст"
color: Колір
refProp: Посилання на властивість
alpha: Прозорість
constant: Стала
refConst: Посилання на сталу
key: Ключ
funcKind: Тип функції
darken: Затемнення
argument: Аргумент
basedProp: Початкова властивість
addConstant: Додати сталу
_sfx: _sfx:
note: "Нотатки" note: "Новий запис"
noteMy: "Мої нотатки" noteMy: "Мої записи"
notification: "Сповіщення" notification: "Сповіщення"
chat: "Чати" chat: "Чати"
chatBg: "Чати (фон)" chatBg: "Чати (фон)"
@ -991,7 +1059,7 @@ _ago:
future: "Майбутнє" future: "Майбутнє"
justNow: "Щойно" justNow: "Щойно"
secondsAgo: "{n}с тому" secondsAgo: "{n}с тому"
minutesAgo: "{n}х тому" minutesAgo: "{n}хв тому"
hoursAgo: "{n}г тому" hoursAgo: "{n}г тому"
daysAgo: "{n}д тому" daysAgo: "{n}д тому"
weeksAgo: "{n} тиж. тому" weeksAgo: "{n} тиж. тому"
@ -1006,36 +1074,66 @@ _tutorial:
title: "Як використовувати Calckey" title: "Як використовувати Calckey"
step1_1: "Ласкаво просимо!" step1_1: "Ласкаво просимо!"
step1_2: "Давайте налаштуємо вас. Ви будете працювати в найкоротші терміни!" step1_2: "Давайте налаштуємо вас. Ви будете працювати в найкоротші терміни!"
step2_1: "Спочатку, будь ласка, заповніть свій профіль" step2_1: "Спочатку, будь ласка, заповніть свій профіль."
step2_2: "Надавши деяку інформацію про себе, іншим людям буде легше зрозуміти, чи step2_2: "Після надання інформації про себе, іншим людям буде легше зрозуміти, чи
хочуть вони бачити ваші записи або стежити за вами." хочуть вони бачити ваші записи або стежити за вами."
step3_1: "Тепер настав час стежити за деякими людьми!" step3_1: "Тепер настав час на когось підписатися!"
step3_2: "Ваша домашня і соціальна стрічки ґрунтуються на тому, за ким ви стежите, step3_2: "Ваша домашня і соціальна стрічки ґрунтуються на тому, за ким ви стежите,
тому для початку спробуйте стежити за кількома акаунтами.\nНатисніть на гурток тому для початку спробуйте стежити за кількома акаунтами.\nНатисніть на гурток
із плюсом у правому верхньому кутку профілю, щоб стежити за ним." із плюсом у правому верхньому кутку профілю, щоб стежити за ним."
step4_1: "Давайте вийдемо на вас" step4_1: "Давайте вийдемо на вас."
step4_2: "Для свого першого повідомлення деякі люди люблять робити {introduction} step4_2: "Для свого першого повідомлення деякі люди люблять робити {introduction}
повідомлення або просте \"Hello world!\"" повідомлення або просте \"Hello world!\""
step5_1: "Тимчасові рамки, скрізь тимчасові рамки!" step5_1: "Стрічки, скрізь одні стрічки!"
step5_2: "У вашому екземплярі включені {timelines} різних часових ліній." step5_2: "У вашому сервері включені {timelines} різні стрічки."
step5_3: "Головна {icon} часова шкала - це шкала, де ви можете бачити повідомлення step5_3: "Головна {icon} стрічка - це стрічка, де ви можете бачити записи тих, на
ваших підписників." кого ви підписалися."
step5_4: "Місцева {icon} тимчасова шкала - це шкала, де ви можете бачити повідомлення step5_4: "Місцева {icon} стрічка - це стрічка, де ви можете бачити записи всіх інших
всіх інших користувачів даного екземпляра" користувачів даного серверу."
step5_5: "Тимчасова шкала Рекомендовані {icon} - це шкала, де ви можете бачити повідомлення step5_5: "Стрічка рекомендованих {icon} - це комбінація домашньої та місцевої стрічок."
від інстанцій, рекомендованих адміністраторами." step5_6: "На стрічці Рекомендованих {icon} ви можете бачити записи з серверів, які
step5_6: "На часовій шкалі Social {icon} відображаються повідомлення від друзів рекомендують адміністратори."
ваших підписників" step5_7: "Глобальна {icon} стрічка - це місце, де ви можете бачити записи від усіх
step5_7: "Глобальна {icon} часова шкала - це місце, де ви можете бачити повідомлення інших приєднаних серверів."
від усіх інших підключених екземплярів"
step6_1: "Отже, що це за місце?" step6_1: "Отже, що це за місце?"
step6_2: "Ну, ви не просто приєдналися до Кальки. Ви приєдналися до порталу в Fediverse, step6_2: "Ну, ви не просто приєдналися до Calckey. Ви увійшли в Fediverse, взаємопов'язану
взаємопов'язаної мережі з тисяч серверів, званих \"інстансами\"." мережу з тисяч серверів."
step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але
цей працює! Це трохи складно, але ви швидко розберетеся" цей працює! Це трохи складно, але ви швидко розберетеся."
step6_4: "Тепер ідіть, вивчайте і розважайтеся!" step6_4: "Тепер ідіть, вивчайте і розважайтеся!"
_2fa: _2fa:
registerSecurityKey: "Зареєструвати новий ключ безпеки" registerSecurityKey: "Зареєструвати новий ключ безпеки"
registerTOTP: Зареєструйте новий пристрій
tapSecurityKey: Будь ласка, дотримуйтесь інструкцій вашого браузера, щоб зареєструвати
апаратний ключ безпеки або ключ-пароль
securityKeyName: Введіть назву ключа
chromePasskeyNotSupported: Паролі Chrome наразі не підтримуються.
renewTOTPOk: Переналаштувати
removeKey: Видалити ключ безпеки
alreadyRegistered: 2FA вже налаштовано.
step2Click: Натиснувши на цей QR-код, ви зможете зареєструвати 2FA у вашому ключі
безпеки або додатку-автентифікаторі для телефону.
step3Title: Введіть код автентифікації
step1: По-перше, встановіть програму 2FA (наприклад, {a} або {b}) на свій пристрій.
securityKeyNotSupported: Ваш браузер не підтримує ключі безпеки.
step4: Відтепер при наступних спробах входу в систему буде запитуватися такий токен.
securityKeyInfo: Окрім автентифікації за відбитком пальця або PIN-кодом, ви також
можете налаштувати автентифікацію за допомогою апаратних ключів безпеки, які підтримують
FIDO2, щоб додатково захистити свій обліковий запис.
removeKeyConfirm: Дійсно видалити ключ {name}?
whyTOTPOnlyRenew: Додаток автентифікатора не можна видалити, доки зареєстровано
ключ безпеки.
renewTOTP: Переналаштувати додаток-автентифікатор
renewTOTPCancel: Скасувати
renewTOTPConfirm: Це призведе до того, що коди підтвердження з попереднього додатку
перестануть працювати
token: 2FA Токен
registerTOTPBeforeKey: Будь ласка, налаштуйте додаток-автентифікатор, щоб зареєструвати
ключ безпеки або пароль.
step2Url: 'Також, ви можете ввести цю URL-адресу, якщо використовуєте десктопну
програму:'
step3: Введіть токен, наданий вашим додатком, щоб завершити налаштування.
step2: Потім відскануйте QR-код, що відображається на цьому екрані.
_permissions: _permissions:
"read:account": "Переглядати дані профілю" "read:account": "Переглядати дані профілю"
"write:account": "Змінити дані акаунту" "write:account": "Змінити дані акаунту"
@ -1051,7 +1149,7 @@ _permissions:
"write:messaging": "Створювати та видаляти повідомлення" "write:messaging": "Створювати та видаляти повідомлення"
"read:mutes": "Переглядати список ігнорованих" "read:mutes": "Переглядати список ігнорованих"
"write:mutes": "Змінювати список ігнорованих" "write:mutes": "Змінювати список ігнорованих"
"write:notes": "Писати і видаляти нотатки" "write:notes": "Створення та видалення записів"
"read:notifications": "Переглядати сповіщення" "read:notifications": "Переглядати сповіщення"
"read:reactions": "Переглядати реакції" "read:reactions": "Переглядати реакції"
"write:reactions": "Змінювати реакції" "write:reactions": "Змінювати реакції"
@ -1064,13 +1162,27 @@ _permissions:
"write:user-groups": "Змінювати групи користувача" "write:user-groups": "Змінювати групи користувача"
"read:channels": "Переглядати канали" "read:channels": "Переглядати канали"
"write:channels": "Змінювати канали" "write:channels": "Змінювати канали"
"read:gallery": Переглянути галерею
"write:gallery": Редагування галереї
"read:gallery-likes": Переглянути список вподобаних записів галереї
"write:notifications": Керування сповіщеннями
"write:gallery-likes": Редагувати список вподобаних записів галереї
_auth: _auth:
shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?" shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?"
shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?" shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?"
denied: "У доступі відмовлено" denied: "У доступі відмовлено"
allPermissions: Повний доступ до облікового запису
permissionAsk: 'Цей додаток запитує наступні дозволи:'
copyAsk: 'Будь ласка, вставте наступний код авторизації в додаток:'
pleaseGoBack: Будь ласка, поверніться до додатку
callback: Повернення до додатку
_antennaSources: _antennaSources:
all: "Всі нотатки" all: "Усі записи"
homeTimeline: "Нотатки тих, на кого ви підписані" homeTimeline: "Записи тих, на кого ви підписані"
instances: Записи від усіх користувачів на сервері
userGroup: Записи від користувачів у вказаній групі
users: Записи обраних користувачів
userList: Дописи користувачів із вказаного списку
_weekday: _weekday:
sunday: "Неділя" sunday: "Неділя"
monday: "Понеділок" monday: "Понеділок"
@ -1091,20 +1203,30 @@ _widgets:
photos: "Фото" photos: "Фото"
digitalClock: "Цифровий годинник" digitalClock: "Цифровий годинник"
federation: "Федіверс" federation: "Федіверс"
postForm: "Створення нотатки" postForm: "Створення запису"
slideshow: "Слайд-шоу" slideshow: "Слайд-шоу"
button: "Кнопка" button: "Кнопка"
onlineUsers: "Користувачі онлайн" onlineUsers: "Користувачі онлайн"
jobQueue: "Черга завдань" jobQueue: "Черга завдань"
serverMetric: "Показники сервера " serverMetric: "Показники сервера"
aiscript: "Консоль AiScript" aiscript: "Консоль AiScript"
_userList:
chooseList: Оберіть список
meiliStatus: Стан сервера
meiliSize: Розмір індексу
rssTicker: RSS-тікер
instanceCloud: Хмара серверів
unixClock: Годинник UNIX
userList: Список користувачів
serverInfo: Інформація про сервер
meiliIndexCount: Індексовані записи
_cw: _cw:
hide: "Сховати" hide: "Сховати"
show: "Показати більше" show: "Показати більше"
chars: "{count} символів" chars: "{count} символів"
files: "{count} файлів" files: "{count} файлів"
_poll: _poll:
noOnlyOneChoice: "Потрібні принаймні два варіанти." noOnlyOneChoice: "Потрібні принаймні два варіанти"
choiceN: "Варіант {n}" choiceN: "Варіант {n}"
noMore: "Більше варіантів додати не можна" noMore: "Більше варіантів додати не можна"
canMultipleVote: "Можна вибрати кілька варіантів" canMultipleVote: "Можна вибрати кілька варіантів"
@ -1127,19 +1249,19 @@ _poll:
remainingSeconds: "Залишилось {s} секунд" remainingSeconds: "Залишилось {s} секунд"
_visibility: _visibility:
public: "Публічний" public: "Публічний"
publicDescription: "Для всіх користувачів" publicDescription: "Ваш запис буде видно в усіх публічних стрічках"
home: "Домівка" home: "Домашній"
homeDescription: "Лише на домашній стрічці" homeDescription: "Лише на домашній стрічці"
followers: "Підписники" followers: "Підписники"
followersDescription: "Тільки для підписників" followersDescription: "Зробити видимим тільки для ваших підписників і згаданих користувачів"
specified: "Особисто" specified: "Особисто"
specifiedDescription: "Лише для певних користувачів" specifiedDescription: "Лише для певних користувачів"
localOnly: "Локально" localOnly: "Локально"
localOnlyDescription: "Приховано для віддалених користувачів" localOnlyDescription: "Приховано для віддалених користувачів"
_postForm: _postForm:
replyPlaceholder: "Відповідь на цю нотатку..." replyPlaceholder: "Відповідь на цей запис..."
quotePlaceholder: "Прокоментуйте цю нотатку..." quotePlaceholder: "Прокоментуйте цей запис..."
channelPlaceholder: "Опублікувати в каналі" channelPlaceholder: "Опублікувати в каналі..."
_placeholders: _placeholders:
a: "Чим займаєтесь?" a: "Чим займаєтесь?"
b: "Що відбувається навколо вас?" b: "Що відбувається навколо вас?"
@ -1155,51 +1277,65 @@ _profile:
metadata: "Додаткова інформація" metadata: "Додаткова інформація"
metadataEdit: "Редагувати додаткову інформацію" metadataEdit: "Редагувати додаткову інформацію"
metadataDescription: "Ви можете вказати до чотирьох пунктів додаткової інформації metadataDescription: "Ви можете вказати до чотирьох пунктів додаткової інформації
у своєму профілі." у своєму профілі. Ви можете додати тег {a} або {l} за допомогою {rel}, щоб підтвердити
посилання у своєму профілі!"
metadataLabel: "Назва" metadataLabel: "Назва"
metadataContent: "Вміст" metadataContent: "Вміст"
changeAvatar: "Змінити аватар" changeAvatar: "Змінити аватар"
changeBanner: "Змінити банер" changeBanner: "Змінити банер"
locationDescription: Якщо ви спочатку введете своє місто, іншим користувачам буде
показано ваш місцевий час.
_exportOrImport: _exportOrImport:
allNotes: "Всі нотатки" allNotes: "Всі записи"
followingList: "Підписки" followingList: "Підписки"
muteList: "Ігнорувати" muteList: "Ігнорувати"
blockingList: "Заблокувати" blockingList: "Заблокувати"
userLists: "Списки" userLists: "Списки"
excludeInactiveUsers: Вилучити неактивних користувачів
excludeMutingUsers: Вилучити заглушених користувачів
_charts: _charts:
federation: "Федіверс" federation: "Федіверс"
apRequest: "Запити" apRequest: "Запити"
usersTotal: "Загальна кількість користувачів" usersTotal: "Загальна кількість користувачів"
activeUsers: "Активні користувачі" activeUsers: "Активні користувачі"
notesTotal: "Загальна кількість нотаток" notesTotal: "Загальна кількість записів"
filesIncDec: "Зміни кількості файлів" filesIncDec: "Зміни кількості файлів"
filesTotal: "Загальна кількість файлів" filesTotal: "Загальна кількість файлів"
storageUsageIncDec: Різниця в використанні ємності диску
remoteNotesIncDec: Різниця в кількості віддалених записів
notesIncDec: Різниця в кількості записів
localNotesIncDec: Різниця в кількості локальних записів
storageUsageTotal: Загальне використання пам'яті
usersIncDec: Різниця в кількості користувачів
_instanceCharts: _instanceCharts:
requests: "Запити" requests: "Запити"
usersTotal: "Сумарна кількість користувачів" usersTotal: "Сумарна кількість користувачів"
notes: "Різниця кількості зроблених записів" notes: "Різниця в кількості зроблених записів"
notesTotal: "Сумарна кількість нотаток" notesTotal: "Сумарна кількість записів"
ff: "Різниця кількості підписників" ff: "Різниця кількості підписників "
ffTotal: "Кількість підписників" ffTotal: "Кількість підписників"
cacheSizeTotal: "Сумарний розмір кешу" cacheSizeTotal: "Сумарний розмір кешу"
files: "Різниця в кількості файлів" files: "Різниця в кількості файлів"
filesTotal: "Сумарна кількість файлів" filesTotal: "Сумарна кількість файлів"
users: Різниця в кількості користувачів
cacheSize: Різниця в розмірі кешу
_timelines: _timelines:
home: "Домівка" home: "Домівка"
local: "Локальна" local: "Локальна"
social: "Соціальна" social: "Соціальна"
global: "Глобальна" global: "Глобальна"
recommended: Рекомендована
_pages: _pages:
newPage: "Створити сторінку" newPage: "Створити сторінку"
editPage: "Редагувати сторінку" editPage: "Редагувати сторінку"
readPage: "Перегляд вихідного коду" readPage: "Перегляд вихідного коду"
created: "Сторінка успішно створена." created: "Сторінка успішно створена"
updated: "Сторінка успішно оновлена." updated: "Сторінка успішно оновлена"
deleted: "Сторінку видалено" deleted: "Сторінку видалено"
pageSetting: "Налаштування сторінки" pageSetting: "Налаштування сторінки"
nameAlreadyExists: "Вказана адреса сторінки вже існує." nameAlreadyExists: "Вказана адреса сторінки вже існує"
invalidNameTitle: "Вказана адреса сторінки неприпустима." invalidNameTitle: "Вказана адреса сторінки неприпустима"
invalidNameText: "Переконайтеся, що не залишили порожнім." invalidNameText: "Переконайтеся, що поле заголовка сторінки не порожнє"
editThisPage: "Редагувати цю сторінку" editThisPage: "Редагувати цю сторінку"
viewSource: "Переглянути вихідний код" viewSource: "Переглянути вихідний код"
viewPage: "Переглянути свої сторінки" viewPage: "Переглянути свої сторінки"
@ -1242,6 +1378,7 @@ _pages:
_post: _post:
text: "Вміст" text: "Вміст"
canvasId: "Ідентифікатор полотна" canvasId: "Ідентифікатор полотна"
attachCanvasImage: Прикріпити зображення полотна
textInput: "Введення тексту" textInput: "Введення тексту"
_textInput: _textInput:
name: "Ім'я змінної" name: "Ім'я змінної"
@ -1262,10 +1399,10 @@ _pages:
id: "Ідентифікатор полотна" id: "Ідентифікатор полотна"
width: "Ширина" width: "Ширина"
height: "Висота" height: "Висота"
note: "Вбудована нотатка" note: "Вбудований запис"
_note: _note:
id: "Ідентифікатор нотатки" id: "Ідентифікатор запису"
idDescription: "Також можна вказати посилання на нотатку" idDescription: "Також можна вказати посилання на запис."
detailed: "Детальний вигляд" detailed: "Детальний вигляд"
switch: "Перемикач" switch: "Перемикач"
_switch: _switch:
@ -1456,7 +1593,7 @@ _pages:
arg1: "Текст" arg1: "Текст"
ref: "Змінні" ref: "Змінні"
aiScriptVar: "Змінна AiScript" aiScriptVar: "Змінна AiScript"
fn: "Функції" fn: "Функція"
_fn: _fn:
slots: "Паз" slots: "Паз"
slots-info: "Використовувати нову лінію як роздільник пазів" slots-info: "Використовувати нову лінію як роздільник пазів"
@ -1508,9 +1645,16 @@ _notification:
followRequestAccepted: "Прийняті підписки" followRequestAccepted: "Прийняті підписки"
groupInvited: "Запрошення до груп" groupInvited: "Запрошення до груп"
app: "Сповіщення від додатків" app: "Сповіщення від додатків"
pollEnded: Опитування закінчено
_actions: _actions:
reply: "Відповісти" reply: "Відповісти"
renote: "Поширити" renote: "Поширення"
followBack: також підписався на вас
emptyPushNotificationMessage: Push-сповіщення були оновлені
voted: проголосував на вашому опитуванні
renoted: поширив ваш запис
reacted: відреагував на ваш запис
pollEnded: Стали доступні результати опитування
_deck: _deck:
alwaysShowMainColumn: "Завжди показувати головну колонку" alwaysShowMainColumn: "Завжди показувати головну колонку"
columnAlign: "Вирівняти стовпці" columnAlign: "Вирівняти стовпці"
@ -1521,16 +1665,27 @@ _deck:
swapDown: "Пересунути вниз" swapDown: "Пересунути вниз"
stackLeft: "У стовпчик вліво" stackLeft: "У стовпчик вліво"
popRight: "Витягнути вправо" popRight: "Витягнути вправо"
profile: "Обліковий запис" profile: "Простір"
_columns: _columns:
main: "Головна" main: "Головна"
widgets: "Віджети" widgets: "Віджети"
notifications: "Сповіщення" notifications: "Сповіщення"
tl: "Стрічка" tl: "Стрічка"
antenna: "Антени" antenna: "Антена"
list: "Списки" list: "Списки"
mentions: "Згадки" mentions: "Згадки"
direct: "Особисте" direct: "Особисті повідомлення"
channel: Канал
newProfile: Новий простір
introduction2: Натисніть на + у правій частині екрана, щоб додавати нові стовпці
по бажанню.
configureColumn: Налаштування стовпців
introduction: Створіть ідеальний інтерфейс для себе, вільно розташовуючи стовпці!
widgetsIntroduction: Будь ласка, виберіть "Редагувати віджети" в меню колонки і
додайте віджет.
renameProfile: Перейменувати простір
deleteProfile: Видалити простір
nameAlreadyExists: Простір із такою назвою вже існує.
removeReaction: Видалити вашу реакцію removeReaction: Видалити вашу реакцію
renoteMute: Ігнорувати поширення renoteMute: Ігнорувати поширення
renoteUnmute: Показувати поширення renoteUnmute: Показувати поширення
@ -1623,3 +1778,358 @@ usernameInfo: Ім'я, яке ідентифікує ваш обліковий
Ім'я користувача не може бути змінено пізніше. Ім'я користувача не може бути змінено пізніше.
noThankYou: Ні, дякую noThankYou: Ні, дякую
keepCw: Зберігати попередження про вміст keepCw: Зберігати попередження про вміст
showEmojisInReactionNotifications: Показувати емодзі у сповіщеннях про реакції
accountMoved: 'Користувач переїхав до нового облікового запису:'
expandOnNoteClickDesc: Якщо цю опцію вимкнено, ви все одно зможете відкривати дописи
в меню, клацнувши правою кнопкою миші або натиснувши на мітку часу.
deleteAccountConfirm: Це призведе до незворотного видалення вашого облікового запису.
Приступити?
unread: Непрочитане
filter: Фільтри
useDrawerReactionPickerForMobile: Відображати вибирач реакцій як шухляду на мобільному
телефоні
leaveGroupConfirm: Ви впевнені, що хочете залишити "{name}"?
clickToFinishEmailVerification: Будь ласка, натисніть [{ok}], щоб завершити перевірку
електронної пошти.
welcomeBackWithName: Ласкаво просимо назад, {name}
overridedDeviceKind: Тип пристрою
themeColor: Колір теми серверу
oneDay: Один день
instanceDefaultLightTheme: Світла тема за замовчуванням для сервера
oneWeek: Одна неділя
instanceDefaultDarkTheme: Темна тема за замовчуванням для сервера
video: Відео
audio: Аудіо
rateLimitExceeded: Перевищено ліміт
numberOfPageCacheDescription: Збільшення цієї величини покращить зручність для користувачів,
але призведе до збільшення навантаження на сервер та використання більшої кількості
пам'яті.
lastActiveDate: Останній раз використовувався у
statusbar: Панель статусу
speed: Швидкість
sensitiveMediaDetection: Виявлення NSFW медіа
cannotUploadBecauseNoFreeSpace: Завантаження не вдалося через брак місця на Диску.
cannotUploadBecauseExceedsFileSizeLimit: Цей файл не може бути завантажений, оскільки
він перевищує максимально дозволений розмір.
account: Обліковий запис
move: Перемістити
pushNotification: Push-сповіщення
subscribePushNotification: Увімкнути push-сповіщення
unsubscribePushNotification: Вимкнути push-сповіщення
pushNotificationAlreadySubscribed: Push-сповіщення вже увімкнено
enterSendsMessage: Натисніть Enter у повідомленнях, щоб надіслати повідомлення (якщо
вимкнено, то Ctrl + Enter)
showAds: Показувати рекламу
customMOTD: Користувацькі MOTD (повідомлення на заставці)
customSplashIcons: Користувацькі іконки заставки (URL)
splash: Заставка
adminCustomCssWarn: Цей параметр слід використовувати, тільки якщо ви знаєте, що він
робить. Введення неправильних значень може призвести до того, що ВСІ клієнти перестануть
нормально працювати. Будь ласка, переконайтеся, що ваш CSS працює належним чином,
протестувавши його в налаштуваннях користувача.
_filters:
followersOnly: Тільки підписники
fromUser: Від користувача
notesBefore: Записи до
withFile: З файлом
fromDomain: З домену
notesAfter: Записи після
followingOnly: Тільки підписки
sendModMail: Надіслати повідомлення про модерацію
enableServerMachineStats: Увімкнути статистику серверного обладнання
enableIdenticonGeneration: Увімкнути генерацію Identicon
_sensitiveMediaDetection:
analyzeVideosDescription: Аналізує відео так само як і зображення. Це трохи збільшить
навантаження на сервер.
description: Зменшує навантаження на серверну модерацію завдяки автоматичному розпізнаванню
NSFW медіа за допомогою машинного навчання. Це трохи збільшить навантаження на
сервер.
sensitivity: Чутливість виявлення
sensitivityDescription: Зменшення чутливості призведе до зменшення кількості хибних
спрацьовувань, тоді як збільшення чутливості призведе до зменшення кількості пропущених
спрацьовувань.
setSensitiveFlagAutomatically: Позначити як NSFW
setSensitiveFlagAutomaticallyDescription: Результати внутрішнього виявлення будуть
збережені, навіть якщо цю опцію вимкнено.
analyzeVideos: Ввімкнути аналіз відео
_emailUnavailable:
used: Ця електронна пошта вже використовується
format: Формат цієї адреси електронної пошти є неправильним
mx: Цей сервер електронної пошти є недійсним
disposable: Використовувати одноразові адреси електронної пошти заборонено
smtp: Цей поштовий сервер не відповідає
_messaging:
dms: Приватні
groups: Групи
_instanceMute:
instanceMuteDescription: Це приховає всі записи/поширення із вказаних серверів,
включно з відповідями користувачам заглушеного серверу.
title: Приховує записи з перелічених серверів.
instanceMuteDescription2: Розділити новими рядками
heading: Список серверів для заглушення
_experiments:
enablePostImports: Ввімкнути імпорт записів
title: Експерименти
postImportsCaption: Дозволяє користувачам імпортувати свої публікації з минулих
облікових записів Calckey, Misskey, Mastodon, Akkoma і Pleroma. Це може спричинити
зниження швидкості під час завантаження, якщо ваша черга перевантажена.
_dialog:
charactersExceeded: 'Перевищено максимальну кількість символів! Обмеження: {current}/{max}'
charactersBelow: 'Недостатньо символів! Обмеження: {current}/{min}'
jumpToSpecifiedDate: Перейти до конкретної дати
quitFullView: Закрити повний вигляд
ffVisibility: Видимість підписок/підписників
numberOfColumn: Кількість стовпців
failedToFetchAccountInformation: Не вдалося отримати інформацію про обліковий запис
reflectMayTakeTime: Може пройти деякий час, перш ніж зміни набудуть чинності.
recentNHours: Останні {n} годин
logoutConfirm: Ви впевнені, що хочете вийти?
enableRecommendedTimeline: Увімкнути рекомендовану стрічку
_accountDelete:
requestAccountDelete: Запросити видалення облікового запису
accountDelete: Видалити обліковий запис
mayTakeTime: Оскільки видалення облікового запису є ресурсоємним процесом, він може
зайняти деякий час, залежно від того, скільки контенту ви створили та скільки
файлів завантажили.
sendEmail: Коли ваш обліковий запис буде видалено, ми повідомимо на вказану вами
електронну пошту.
started: Процес видалення розпочався.
inProgress: Наразі триває видалення
_preferencesBackups:
deleteConfirm: Видалити резервну копію {name}?
applyConfirm: Ви дійсно хочете застосувати резервну копію "{name}" до цього пристрою?
Існуючі налаштування цього пристрою буде замінено.
saveConfirm: Зберегти резервну копію як {name}?
saveNew: Зберегти нову резервну копію
save: Зберегти зміни
inputName: Будь ласка, введіть назву для цієї резервної копії
loadFile: Завантажити з файлу
updatedAt: 'Оновлено: {date} {time}'
invalidFile: Неправильний формат файлу
apply: Застосувати до цього пристрою
list: Створені резервні копії
cannotSave: Збереження невдале
nameAlreadyExists: Резервна копія з назвою "{name}" вже існує. Будь ласка, введіть
іншу назву.
renameConfirm: Перейменувати цю резервну копію з "{old}" на "{new}"?
noBackups: Резервних копій немає. Ви можете створити резервну копію налаштувань
клієнта на цьому сервері за допомогою "Створити нову резервну копію".
createdAt: 'Створено: {date} {time}'
cannotLoad: Не вдалося завантажити
beta: Бета
customMOTDDescription: Користувацькі повідомлення для MOTD (заставки), розділені новими
рядками, які будуть показуватися випадковим чином щоразу, коли користувач завантажує/перезавантажує
сторінку.
replayTutorial: Перезапустити туторіал
_forgotPassword:
ifNoEmail: Якщо ви не використовували електронну пошту під час реєстрації, зверніться
до адміністратора серверу.
enterEmail: Введіть адресу електронної пошти, яку ви використовували для реєстрації.
На неї буде надіслано посилання, за яким ви зможете скинути пароль.
contactAdmin: Цей сервер не підтримує використання адрес електронної пошти, будь
ласка, зверніться до адміністратора сервера, щоб скинути пароль.
reactionPickerSkinTone: Бажаний колір шкіри емодзі
addInstance: Додати сервер
jumpToPrevious: Перейти до попереднього
listsDesc: Списки дозволяють створювати стрічки із вказаними користувачами. Доступ
до них можна отримати на сторінці стрічок.
channelFederationWarn: Канали наразі федеруються з іншими серверами
lastCommunication: Останнє повідомлення
edited: Відредаговано {date} о {time}
confirmToUnclipAlreadyClippedNote: Цей запис уже в підбірці "{name}". Чи бажаєте ви
натомість видалити пост із підбірки?
quickAction: Швидкі дії
remoteOnly: Тільки віддалені
failedToUpload: Помилка завантаження
moveFrom: Мігрувати на цей обліковий запис зі старого облікового запису
preventAiLearning: Захист від скрепінгу ШІ-ботів
moveAccountDescription: Цей процес є незворотнім. Переконайтеся, що ви створили псевдонім
для цього акаунта в новому акаунті перед переїздом. Будь ласка, введіть тег акаунта
у форматі @person@server.com
_signup:
almostThere: Майже готово
emailAddressInfo: Будь ласка, введіть свою адресу електронної пошти. Вона не буде
опублікована.
emailSent: На вашу електронну адресу ({email}) було надіслано лист із підтвердженням.
Будь ласка, перейдіть за посиланням, щоб завершити створення облікового запису.
defaultValueIs: 'За замовчуванням: {value}'
shareWithNote: Поділитися з записом
classic: Відцентрований
size: Розмір
slow: Повільно
alt: ALT
auto: Автоматично
oneHour: Одна година
instanceDefaultThemeDescription: Введіть код теми в об'єктному форматі.
cropImageAsk: Чи бажаєте ви обрізати це зображення?
noEmailServerWarning: Поштовий сервер не налаштовано.
thereIsUnresolvedAbuseReportWarning: Є не розглянуті звіти.
image: Зображення
check: Перевірити
isSystemAccount: Цей акаунт створений і автоматично управляється системою. Будь ласка,
не модеруйте, не редагуйте, не видаляйте та не втручайтеся в цей акаунт будь-яким
іншим чином, інакше це може призвести до поломки вашого серверу.
document: Документація
driveCapOverrideCaption: Ви можете скинути ємність до значення за замовчуванням, ввівши
значення 0 або менше.
numberOfPageCache: Кількість кешованих сторінок
pleaseSelect: Оберіть варіант
refreshInterval: 'Інтервал оновлення '
enableAutoSensitive: Автоматичне маркування NSFW
cannotUploadBecauseInappropriate: Цей файл не може бути завантажений тому що його
частини були виявлені як потенційне NSFW.
sendPushNotificationReadMessageCaption: На короткий час буде показано сповіщення з
текстом "{emptyPushNotificationMessage}". Це може призвести до збільшення споживання
заряду акумулятора вашого пристрою, якщо це можливо.
pushNotificationNotSupported: Ваш браузер або сервер не підтримує push-сповіщення
showUpdates: Показувати спливаюче вікно при оновленні Calckey
updateAvailable: Можливо, є доступне оновлення!
recommendedInstancesDescription: Рекомендовані сервери відокремлюються переведенням
рядка, щоб з'явитися на стрічці рекомендацій.
caption: Автоматичний підпис
showAdminUpdates: Вказати, що доступна нова версія Calckey (тільки для адміністратора)
defaultReaction: Емодзі реакція за замовчуванням для вихідних і вхідних записів
license: Ліцензія
indexPosts: Індексувати пости
indexFrom: Індексувати записи з ID
indexFromDescription: Залиште порожнім, щоб індексувати кожен запис
indexNotice: Зараз відбувається індексація. Це, ймовірно, займе деякий час, будь ласка,
не перезавантажуйте сервер принаймні годину.
signupsDisabled: Реєстрація на цьому сервері наразі відключена, але ви завжди можете
зареєструватися на іншому сервері! Якщо у вас є код запрошення на цей сервер, будь
ласка, введіть його нижче.
findOtherInstance: Знайти інший сервер
customKaTeXMacro: Користувацькі макроси KaTeX
enableCustomKaTeXMacro: Увімкнути користувацькі макроси KaTeX
apps: Додатки
isModerator: Модератор
isAdmin: Адміністратор
isPatron: Патрон Calckey
swipeOnMobile: Дозволити гортання між сторінками
migration: Міграція
swipeOnDesktop: Дозволити свайп у мобільному стилі на десктопі
logoImageUrl: URL-адреса зображення логотипу
moveTo: Перенести поточний обліковий запис на новий
moveFromDescription: Це встановить псевдонім вашого старого облікового запису, щоб
ви могли перейти зі старого облікового запису до цього поточного. Зробіть це ДО
переходу зі старого акаунта. Будь ласка, введіть тег акаунта у форматі @person@server.com
moveToLabel: 'Обліковий запис, на який ви мігруєте:'
moveAccount: Перемістити обліковий запис!
moveFromLabel: 'Обліковий запис, з якого ви мігруєте:'
_plugin:
install: Встановлення плагінів
manage: Керування плагінами
installWarn: Будь ласка, не встановлюйте ненадійні плагіни.
_skinTones:
yellow: Жовтий
mediumLight: Помірно-світлий
medium: Помірний
mediumDark: Помірно-темний
dark: Темний
light: Світлий
tenMinutes: 10 хвилин
expandOnNoteClick: Відкрити запис кліком
preferencesBackups: Резервне копіювання
unlikeConfirm: Дійсно видалити вподобайку?
fullView: Повний вигляд
postToGallery: Опублікувати в галереї
memo: Нотатки
allowedInstancesDescription: Хости серверів, які будуть допущені до федерації, кожен
з яких відокремлюється новим рядком (стосується лише приватного режиму).
squareAvatars: Квадратні аватарки
aiChanMode: Режим ШІ
controlPanel: Панель керування
manageAccounts: Керування обліковими записами
incorrectPassword: Неправильний пароль.
voteConfirm: Підтвердити свій голос за "{choice}"?
leaveGroup: Залишити групу
smartphone: Смартфон
mutePeriod: Тривалість глушіння
requireAdminForView: Ви маєте увійти з облікового запису адміністратора, щоб переглянути
це.
fast: Швидко
isBot: Цей обліковий запис є ботом
isLocked: Цей обліковий запис має схвалення запитів на підписку
silenceThisInstance: Ігнорувати цей сервер
hideOnlineStatusDescription: Приховування вашого онлайн-статусу знижує зручність деяких
функцій, таких як пошук.
accountDeletionInProgress: Наразі триває видалення облікового запису
makeReactionsPublic: Зробити історію реакцій публічною
continueThread: Показати наступні відповіді
unmuteThread: Скасувати глушіння гілки
ffVisibilityDescription: Дозволяє налаштувати, хто може бачити, на кого ви підписані
і хто підписаний на вас.
tablet: Планшет
cropImage: Обрізати зображення
recentNDays: Останні {n} днів
navbar: Панель навігації
noGraze: Будь ласка, вимкніть розширення браузера "Graze для Mastodon", оскільки воно
заважає роботі Calckey.
preventAiLearningDescription: Попросити сторонні мовні моделі ШІ не вивчати вміст,
який ви завантажуєте, наприклад, записи та зображення.
userSaysSomethingReasonReply: '{name} відповів на пост з {reason}'
secureMode: Безпечний режим (Authorized Fetch)
seperateRenoteQuote: Розділити кнопки поширення та цитати
makeReactionsPublicDescription: Це зробить список усіх ваших минулих реакцій публічно
видимим.
muteThread: Заглушити гілку
sendPushNotificationReadMessage: Видаляти push-сповіщення після того, як відповідні
сповіщення або повідомлення будуть прочитані
unclip: Видалити з підбірки
silencedInstances: Ігноровані сервери
typeToConfirm: Введіть {x} щоб підтвердити
silencedWarning: Ця сторінка відображається тому, що ці користувачі з серверів, які
ваш адміністратор заглушив, тому вони потенційно можуть бути спамом.
shuffle: Перетасувати
ratio: Співвідношення
secureModeInfo: У разі запитів з інших серверів не надсилати непідтверджену відповідь.
pubSub: Облікові записи Pub/Sub
driveCapOverrideLabel: Змінити ємність диску для цього користувача
deleteAccount: Видалити обліковий запис
type: Тип
enableAutoSensitiveDescription: Дозволяє автоматично виявляти та позначати медіафайли
NSFW за допомогою машинного навчання, де це можливо. Навіть якщо цю опцію вимкнено,
вона може бути увімкнена на всьому сервері.
recommendedInstances: Рекомендовані сервери
noteId: Ідентифікатор запису
showPopup: Сповіщати користувачів спливаючим вікном
showWithSparkles: Показати з блиском
youHaveUnreadAnnouncements: У вас є непрочитані оголошення
donationLink: Посилання на сторінку для внесків
neverShow: Не показувати знову
remindMeLater: Можливо пізніше
removeQuote: Видалити цитату
removeRecipient: Видалити одержувача
removeMember: Видалити члена
silencedInstancesDescription: Вкажіть імена хостів серверів, які ви хочете ігнорувати.
Облікові записи на перелічених серверах вважаються "Ігнорованими", можуть робити
лише запити на підписку і не можуть згадувати локальні облікові записи, якщо на
них не підписалися. Це не вплине на заблоковані сервери.
hiddenTagsDescription: 'Перелічіть хештеги (без #), які ви хочете приховати з трендів
і дослідження. Приховані хештеги все одно можна знайти іншими способами.'
antennasDesc: "Антени показують нові дописи, що відповідають встановленим вами критеріям!\n
Доступ до них можна отримати зі сторінки стрічок."
clipsDesc: Підбірки схожі на категоризовані закладки, до яких можна надавати спільний
доступ. Ви можете створювати підбірки з меню окремих записів.
migrationConfirm: "Ви точно впевнені, що хочете перенести свій обліковий запис на
{account}? Якщо ви це зробите, ви не зможете скасувати цю операцію і не зможете
користуватися своїм обліковим записом як раніше.\nТакож, будь ласка, переконайтеся,
що ви вибрали цей поточний обліковий запис як обліковий запис, з якого ви переходите."
customKaTeXMacroDescription: 'Налаштуйте макроси, щоб легко писати математичні вирази!
Позначення відповідає визначенню команд LaTeX і записується у вигляді \newcommand{\
name}{content} або \newcommand{\name}[number of arguments]{content}. Наприклад,
\newcommand{\add}[2]{#1 + #2} розширить \add{3}{foo} to 3 + foo. Фігурні дужки навколо
назви макросу можна змінити на круглі або квадратні. Це вплине на дужки, що використовуються
для аргументів. В одному рядку можна визначити один (і тільки один) макрос, і жоден
рядок не можна розривати посередині визначення. Неправильні рядки просто ігноруються.
Підтримуються лише прості функції заміни рядків; розширений синтаксис, такий як
умовне розгалуження, не може бути використаний тут.'
activeEmailValidationDescription: Вмикає більш сувору перевірку адрес електронної
пошти, яка включає перевірку на наявність одноразових адрес і перевірку того, чи
дійсно з нею можна зв'язатися. Якщо цей прапорець знято, перевіряється лише формат
електронної пошти.
customSplashIconsDescription: URL-адреси іконок для заставки, розділені новими рядками,
які будуть показуватися випадковим чином щоразу, коли користувач завантажує/перезавантажує
сторінку. Будь ласка, переконайтеся, що зображення знаходяться на статичній URL-адресі,
бажано, щоб вони були змінені до розміру 192x192.
verifiedLink: Перевірене посилання

View file

@ -1342,7 +1342,7 @@ _profile:
youCanIncludeHashtags: "Bạn có thể dùng hashtag trong tiểu sử." youCanIncludeHashtags: "Bạn có thể dùng hashtag trong tiểu sử."
metadata: "Thông tin bổ sung" metadata: "Thông tin bổ sung"
metadataEdit: "Sửa thông tin bổ sung" metadataEdit: "Sửa thông tin bổ sung"
metadataDescription: "Sử dụng phần này, bạn có thể hiển thị các mục thông tin bổ sung trong hồ sơ của mình." metadataDescription: "Sử dụng phần này, bạn có thể hiển thị các mục thông tin bổ sung trong hồ sơ của mình. Bạn có thể thêm thẻ {a} hoặc thẻ {l} với {rel} để xác minh liên kết trên tiểu sử của mình!"
metadataLabel: "Nhãn" metadataLabel: "Nhãn"
metadataContent: "Nội dung" metadataContent: "Nội dung"
changeAvatar: "Đổi ảnh đại diện" changeAvatar: "Đổi ảnh đại diện"

View file

@ -1402,7 +1402,7 @@ _profile:
youCanIncludeHashtags: "您可以包含一个话题标签。" youCanIncludeHashtags: "您可以包含一个话题标签。"
metadata: "附加信息" metadata: "附加信息"
metadataEdit: "附加信息编辑" metadataEdit: "附加信息编辑"
metadataDescription: "使用这些,您可以在您的个人资料中显示其它信息字段。" metadataDescription: "使用这些,您可以在您的个人资料中显示其它信息字段。您可以添加带有 {rel} 的 {a} 标签或 {l} 标签来验证您个人资料上的链接!"
metadataLabel: "标签" metadataLabel: "标签"
metadataContent: "内容" metadataContent: "内容"
changeAvatar: "修改头像" changeAvatar: "修改头像"

View file

@ -984,6 +984,12 @@ _aboutMisskey:
donate: "贊助Calckey" donate: "贊助Calckey"
morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰" morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰"
patrons: "贊助者" patrons: "贊助者"
patronsList: 按時間順序列出,而不是按贊助規模列出。使用上面的連結贊助,在這裡獲得顯示您名字的機會!
sponsors: Calckey 贊助者們
donateTitle: 覺得 Calckey 棒嗎?
pleaseDonateToCalckey: 請考慮向 Calckey 贊助以支持其發展。
pleaseDonateToHost: 還請考慮捐贈給您在使用的伺服器 {host},以支援龐大的運營成本。
donateHost: 贊助給 {host}
_nsfw: _nsfw:
respect: "隱藏敏感內容" respect: "隱藏敏感內容"
ignore: "不隱藏敏感內容" ignore: "不隱藏敏感內容"
@ -1060,6 +1066,8 @@ _mfm:
position: 位置 position: 位置
alwaysPlay: 自動播放所有MFM動畫 alwaysPlay: 自動播放所有MFM動畫
positionDescription: 按指定數量移動內容。 positionDescription: 按指定數量移動內容。
advancedDescription: 如果禁用,則僅允許基本標記,除非正在播放 MFM 動畫
advanced: 高級MFM
_instanceTicker: _instanceTicker:
none: "隱藏" none: "隱藏"
remote: "向遠端使用者顯示" remote: "向遠端使用者顯示"
@ -1202,14 +1210,14 @@ _tutorial:
step1_1: "歡迎!" step1_1: "歡迎!"
step1_2: "讓我們把你安排好。你很快就會啟動並運行!" step1_2: "讓我們把你安排好。你很快就會啟動並運行!"
step2_1: "首先,請完成你的個人資料。" step2_1: "首先,請完成你的個人資料。"
step2_2: "通過提供一些關於你自己的資料,其他人會更容易了解他們是否想看到你的帖子或關注你。" step2_2: "通過提供一些關於你自己的資料,其他人會更容易了解他們是否想看到你的貼文或關注你。"
step3_1: "現在是時候追隨一些人了!" step3_1: "現在是時候追隨一些人了!"
step3_2: "你的主頁和社交時間線是基於你所追蹤的人,所以試著先追蹤幾個帳戶。\n點擊個人資料右上角的加號圈就可以關注它。" step3_2: "你的主頁和社交時間線是基於你所追蹤的人,所以試著先追蹤幾個帳戶。\n點擊個人資料右上角的加號圈就可以關注它。"
step4_1: "讓我們出去找你。" step4_1: "讓我們出去找你。"
step4_2: "對於他們的第一條信息,有些人喜歡做 {introduction} 或一個簡單的 \"hello world!\"" step4_2: "對於他們的第一條信息,有些人喜歡做 {introduction} 或一個簡單的 \"hello world!\""
step5_1: "時間線,到處都是時間線!" step5_1: "時間線,到處都是時間線!"
step5_2: "您的伺服器已啟用了{timelines}個時間線。" step5_2: "您的伺服器已啟用了{timelines}個時間線。"
step5_3: "首頁 {icon} 時間線是顯示你追蹤的帳號的帖子。" step5_3: "首頁 {icon} 時間線是顯示你追蹤的帳號的貼文。"
step5_4: "本地 {icon} 時間線是你可以看到伺服器中所有其他用戶的貼文的時間線。" step5_4: "本地 {icon} 時間線是你可以看到伺服器中所有其他用戶的貼文的時間線。"
step5_5: "社交 {icon} 時間線是你的 首頁時間線 和 本地時間線 的結合體。" step5_5: "社交 {icon} 時間線是你的 首頁時間線 和 本地時間線 的結合體。"
step5_6: "推薦 {icon} 時間線是顯示你的伺服器管理員推薦的貼文。" step5_6: "推薦 {icon} 時間線是顯示你的伺服器管理員推薦的貼文。"
@ -1361,7 +1369,7 @@ _profile:
youCanIncludeHashtags: "你也可以在「關於我」中加上 #tag。" youCanIncludeHashtags: "你也可以在「關於我」中加上 #tag。"
metadata: "進階資訊" metadata: "進階資訊"
metadataEdit: "編輯進階資訊" metadataEdit: "編輯進階資訊"
metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。" metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。您可以添加帶有 {rel} 的 {a} 標籤或 {l} 標籤來驗證您個人資料上的鏈接!"
metadataLabel: "標籤" metadataLabel: "標籤"
metadataContent: "内容" metadataContent: "内容"
changeAvatar: "更換大頭貼" changeAvatar: "更換大頭貼"
@ -1820,12 +1828,12 @@ _experiments:
title: 試驗功能 title: 試驗功能
findOtherInstance: 找找另一個伺服器 findOtherInstance: 找找另一個伺服器
noGraze: 瀏覽器擴展 "Graze for Mastodon" 會與Calckey發生衝突請停用該擴展。 noGraze: 瀏覽器擴展 "Graze for Mastodon" 會與Calckey發生衝突請停用該擴展。
userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的帖子' userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的貼文'
pushNotificationNotSupported: 你的瀏覽器或伺服器不支援推送通知 pushNotificationNotSupported: 你的瀏覽器或伺服器不支援推送通知
accessibility: 輔助功能 accessibility: 輔助功能
userSaysSomethingReasonReply: '{name} 回復了包含 {reason} 的帖子' userSaysSomethingReasonReply: '{name} 回覆了包含 {reason} 的貼文'
hiddenTags: 隱藏主題標籤 hiddenTags: 隱藏主題標籤
indexPosts: 索引帖子 indexPosts: 索引貼文
indexNotice: 現在開始索引。 這可能需要一段時間,請不要在一個小時內重啟你的伺服器。 indexNotice: 現在開始索引。 這可能需要一段時間,請不要在一個小時內重啟你的伺服器。
deleted: 已刪除 deleted: 已刪除
editNote: 編輯筆記 editNote: 編輯筆記
@ -1861,9 +1869,33 @@ audio: 音訊
sendPushNotificationReadMessageCaption: 包含文本 “{emptyPushNotificationMessage}” 的通知將顯示一小段時間。 sendPushNotificationReadMessageCaption: 包含文本 “{emptyPushNotificationMessage}” 的通知將顯示一小段時間。
這可能會增加您設備的電池使用量(如果適用)。 這可能會增加您設備的電池使用量(如果適用)。
channelFederationWarn: 頻道功能尚未與聯邦宇宙連動 channelFederationWarn: 頻道功能尚未與聯邦宇宙連動
swipeOnMobile: 允許在頁面之間滑動 swipeOnMobile: 允許以滑動在頁面之間切換
sendPushNotificationReadMessage: 閱讀相關通知或消息後刪除推送通知 sendPushNotificationReadMessage: 閱讀相關通知或消息後刪除推送通知
image: 圖片 image: 圖片
seperateRenoteQuote: 分別獨立的轉傳及引用按鈕 seperateRenoteQuote: 分別獨立的轉傳及引用按鈕
clipsDesc: 摘錄就像一個可以分享的書籤。 你可以從每個貼文的菜單創建新摘錄或將貼文加入已有的摘錄。 clipsDesc: 摘錄就像一個可以分享的書籤。 你可以從每個貼文的菜單創建新摘錄或將貼文加入已有的摘錄。
noteId: 貼文 ID noteId: 貼文 ID
sendModMail: 發送審核通知
enableIdenticonGeneration: 啟用碎片生成
enableServerMachineStats: 啟用伺服器硬體統計資訊
reactionPickerSkinTone: 首選表情符號膚色
indexFromDescription: 留空以索引每個貼文
preventAiLearning: 防止 AI 機器人抓取
preventAiLearningDescription: 請求第三方 AI 語言模型不要研究您上傳的內容,例如貼文和圖像。
indexFrom: 從貼文 ID 開始的索引
isLocked: 該帳戶已獲得以下批准
isModerator: 板主
isAdmin: 管理員
isPatron: Calckey 項目贊助者
silencedWarning: 顯示此頁面是因為這些使用者來自您伺服器管理員已靜音的伺服器,因此他們可能是垃圾訊息。
signupsDisabled: 該伺服器上的註冊當前已被禁用,但您隨時可以在另一台伺服器上註冊!或是您有該伺服器的邀請碼,請在下面輸入。
showPopup: 通過彈出式視窗通知用戶
showWithSparkles: 閃閃發光的顯示
youHaveUnreadAnnouncements: 您有未讀的公告
donationLink: 連結到贊助頁面
neverShow: 不再顯示
remindMeLater: 可能之後
removeQuote: 删除引用
removeRecipient: 刪除收件者
removeMember: 刪除成員
isBot: 此帳戶是機器人

View file

@ -1,6 +1,6 @@
{ {
"name": "calckey", "name": "calckey",
"version": "14.0.0-dev77", "version": "14.0.0-dev83",
"codename": "aqua", "codename": "aqua",
"repository": { "repository": {
"type": "git", "type": "git",
@ -57,7 +57,7 @@
"gulp-replace": "1.1.4", "gulp-replace": "1.1.4",
"gulp-terser": "2.1.0", "gulp-terser": "2.1.0",
"install-peers": "^1.0.4", "install-peers": "^1.0.4",
"rome": "^12.1.3", "rome": "^v12.1.3-nightly.f65b0d9",
"start-server-and-test": "1.15.2", "start-server-and-test": "1.15.2",
"typescript": "5.1.6" "typescript": "5.1.6"
} }

View file

@ -0,0 +1,13 @@
Copyright 2023 Calckey
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,16 @@
export class tweakVarcharLength1678426061773 {
name = "tweakVarcharLength1678426061773";
async up(queryRunner) {
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(1024)`,
undefined,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(1024)`,
undefined,
);
}
async down(queryRunner) {}
}

View file

@ -458,6 +458,20 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "combine"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4"
dependencies = [
"bytes",
"futures-core",
"memchr",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]] [[package]]
name = "console" name = "console"
version = "0.15.7" version = "0.15.7"
@ -486,6 +500,16 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "core-foundation"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.4" version = "0.8.4"
@ -1278,11 +1302,14 @@ dependencies = [
"futures", "futures",
"indicatif", "indicatif",
"native-utils", "native-utils",
"redis",
"sea-orm",
"sea-orm-migration", "sea-orm-migration",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"tokio", "tokio",
"url",
"urlencoding", "urlencoding",
] ]
@ -1509,6 +1536,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]] [[package]]
name = "os_str_bytes" name = "os_str_bytes"
version = "6.5.0" version = "6.5.0"
@ -1843,6 +1876,29 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "redis"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ea8c51b5dc1d8e5fd3350ec8167f464ec0995e79f2e90a075b63371500d557f"
dependencies = [
"async-trait",
"bytes",
"combine",
"futures-util",
"itoa",
"percent-encoding",
"pin-project-lite",
"rustls 0.21.3",
"rustls-native-certs",
"ryu",
"sha1_smol",
"tokio",
"tokio-rustls 0.24.1",
"tokio-util",
"url",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.16" version = "0.2.16"
@ -2043,6 +2099,30 @@ dependencies = [
"webpki", "webpki",
] ]
[[package]]
name = "rustls"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b19faa85ecb5197342b54f987b142fb3e30d0c90da40f80ef4fa9a726e6676ed"
dependencies = [
"log",
"ring",
"rustls-webpki",
"sct",
]
[[package]]
name = "rustls-native-certs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"schannel",
"security-framework",
]
[[package]] [[package]]
name = "rustls-pemfile" name = "rustls-pemfile"
version = "1.0.2" version = "1.0.2"
@ -2052,6 +2132,16 @@ dependencies = [
"base64 0.21.2", "base64 0.21.2",
] ]
[[package]]
name = "rustls-webpki"
version = "0.101.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e"
dependencies = [
"ring",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.12" version = "1.0.12"
@ -2076,6 +2166,15 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]]
name = "schannel"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88"
dependencies = [
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.12" version = "0.8.12"
@ -2286,6 +2385,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.17" version = "1.0.17"
@ -2370,6 +2492,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha1_smol"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.6" version = "0.10.6"
@ -2518,7 +2646,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"rand", "rand",
"rust_decimal", "rust_decimal",
"rustls", "rustls 0.20.8",
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
@ -2564,7 +2692,7 @@ checksum = "804d3f245f894e61b1e6263c84b23ca675d96753b5abfd5cc8597d86806e8024"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls 0.23.4",
] ]
[[package]] [[package]]
@ -2778,11 +2906,21 @@ version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
dependencies = [ dependencies = [
"rustls", "rustls 0.20.8",
"tokio", "tokio",
"webpki", "webpki",
] ]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.3",
"tokio",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.14" version = "0.1.14"
@ -2949,6 +3087,7 @@ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde",
] ]
[[package]] [[package]]

View file

@ -9,7 +9,7 @@ members = ["migration"]
[features] [features]
default = [] default = []
noarray = [] noarray = []
napi = ["dep:napi", "dep:napi-derive", "dep:radix_fmt"] napi = ["dep:napi", "dep:napi-derive"]
[lib] [lib]
crate-type = ["cdylib", "lib"] crate-type = ["cdylib", "lib"]
@ -31,11 +31,11 @@ serde_json = "1.0.96"
thiserror = "1.0.40" thiserror = "1.0.40"
tokio = { version = "1.28.1", features = ["full"] } tokio = { version = "1.28.1", features = ["full"] }
utoipa = "3.3.0" utoipa = "3.3.0"
radix_fmt = "1.0.0"
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.13.1", default-features = false, features = ["napi6", "tokio_rt"], optional = true } napi = { version = "2.13.1", default-features = false, features = ["napi6", "tokio_rt"], optional = true }
napi-derive = { version = "2.12.0", optional = true } napi-derive = { version = "2.12.0", optional = true }
radix_fmt = { version = "1.0.0", optional = true }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.3.0" pretty_assertions = "1.3.0"

View file

@ -12,18 +12,18 @@ test("convert to mastodon id", (t) => {
t.is(convertId("9gf61ehcxv", IdConvertType.MastodonId), "960365976481219"); t.is(convertId("9gf61ehcxv", IdConvertType.MastodonId), "960365976481219");
t.is( t.is(
convertId("9fbr9z0wbrjqyd3u", IdConvertType.MastodonId), convertId("9fbr9z0wbrjqyd3u", IdConvertType.MastodonId),
"3954607381600562394", "2083785058661759970208986",
); );
t.is( t.is(
convertId("9fbs680oyviiqrol9md73p8g", IdConvertType.MastodonId), convertId("9fbs680oyviiqrol9md73p8g", IdConvertType.MastodonId),
"3494513243013053824", "5878598648988104013828532260828151168",
); );
}); });
test("create cuid2 with timestamp prefix", (t) => { test("create cuid2 with timestamp prefix", (t) => {
nativeInitIdGenerator(16, ""); nativeInitIdGenerator(16, "");
t.not(nativeCreateId(BigInt(Date.now())), nativeCreateId(BigInt(Date.now()))); t.not(nativeCreateId(Date.now()), nativeCreateId(Date.now()));
t.is(nativeCreateId(BigInt(Date.now())).length, 16); t.is(nativeCreateId(Date.now()).length, 16);
}); });
test("create random string", (t) => { test("create random string", (t) => {

View file

@ -21,6 +21,9 @@ futures = { version = "0.3.28", optional = true }
serde_yaml = "0.9.21" serde_yaml = "0.9.21"
serde = { version = "1.0.163", features = ["derive"] } serde = { version = "1.0.163", features = ["derive"] }
urlencoding = "2.1.2" urlencoding = "2.1.2"
redis = { version = "0.23.0", features = ["tokio-rustls-comp"] }
sea-orm = "0.11.3"
url = { version = "2.4.0", features = ["serde"] }
[dependencies.sea-orm-migration] [dependencies.sea-orm-migration]
version = "0.11.0" version = "0.11.0"

View file

@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
mod m20230531_180824_drop_reversi; mod m20230531_180824_drop_reversi;
mod m20230627_185451_index_note_url; mod m20230627_185451_index_note_url;
mod m20230709_000510_move_antenna_to_cache;
pub struct Migrator; pub struct Migrator;
@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
vec![ vec![
Box::new(m20230531_180824_drop_reversi::Migration), Box::new(m20230531_180824_drop_reversi::Migration),
Box::new(m20230627_185451_index_note_url::Migration), Box::new(m20230627_185451_index_note_url::Migration),
Box::new(m20230709_000510_move_antenna_to_cache::Migration),
] ]
} }
} }

View file

@ -0,0 +1,248 @@
use redis::streams::StreamMaxlen;
use sea_orm::Statement;
use sea_orm_migration::prelude::*;
use std::env;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let cache_url = env::var("CACHE_URL").unwrap();
let skip_copy = env::var("ANTENNA_MIGRATION_SKIP").unwrap_or_default();
let copy_limit = env::var("ANTENNA_MIGRATION_COPY_LIMIT").unwrap_or_default();
let read_limit: u64 = env::var("ANTENNA_MIGRATION_READ_LIMIT")
.unwrap_or("10000".to_string())
.parse()
.unwrap();
let copy_limit: i64 = match copy_limit.parse() {
Ok(limit) => limit,
Err(_) => 0,
};
if skip_copy == "true" {
println!("Skipped antenna migration");
} else {
let prefix = env::var("CACHE_PREFIX").unwrap();
let db = manager.get_connection();
let bk = manager.get_database_backend();
let count_stmt =
Statement::from_string(bk, "SELECT COUNT(1) FROM antenna_note".to_owned());
let total_num = db
.query_one(count_stmt)
.await?
.unwrap()
.try_get_by_index::<i64>(0)?;
let copy_limit = if copy_limit > 0 {
copy_limit
} else {
total_num
};
println!(
"Copying {} out of {} entries in antenna_note.",
copy_limit, total_num
);
let stmt_base = Query::select()
.column((AntennaNote::Table, AntennaNote::Id))
.column(AntennaNote::AntennaId)
.column(AntennaNote::NoteId)
.from(AntennaNote::Table)
.order_by((AntennaNote::Table, AntennaNote::Id), Order::Asc)
.limit(read_limit)
.to_owned();
let mut stmt = stmt_base.clone();
let client = redis::Client::open(cache_url).unwrap();
let mut redis_conn = client.get_connection().unwrap();
let mut remaining = total_num;
let mut pagination: i64 = 0;
loop {
let res = db.query_all(bk.build(&stmt)).await?;
if res.len() == 0 {
break;
}
let val: Vec<(String, String, String)> = res
.iter()
.filter_map(|q| q.try_get_many_by_index().ok())
.collect();
remaining -= val.len() as i64;
if remaining <= copy_limit {
let mut pipe = redis::pipe();
for v in &val {
pipe.xadd_maxlen(
format!("{}:antennaTimeline:{}", prefix, v.1),
StreamMaxlen::Approx(200),
"*",
&[("note", v.2.to_owned())],
)
.ignore();
}
pipe.query::<()>(&mut redis_conn).unwrap();
}
let copied = total_num - remaining;
let copied = std::cmp::min(copied, total_num);
pagination += 1;
if pagination % 10 == 0 {
println!(
"Migrating antenna [{:.2}%]",
(copied as f64 / total_num as f64) * 100_f64,
);
}
if let Some((last_id, _, _)) = val.last() {
stmt = stmt_base
.clone()
.and_where(
Expr::col((AntennaNote::Table, AntennaNote::Id)).gt(last_id.to_owned()),
)
.to_owned();
} else {
break;
}
}
println!("Migrating antenna [100.00%]");
}
manager
.drop_table(
Table::drop()
.table(AntennaNote::Table)
.if_exists()
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(AntennaNote::Table)
.if_not_exists()
.col(
ColumnDef::new(AntennaNote::Id)
.string_len(32)
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(AntennaNote::NoteId)
.string_len(32)
.not_null(),
)
.col(
ColumnDef::new(AntennaNote::AntennaId)
.string_len(32)
.not_null(),
)
.col(
ColumnDef::new(AntennaNote::Read)
.boolean()
.default(false)
.not_null(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("IDX_0d775946662d2575dfd2068a5f")
.table(AntennaNote::Table)
.col(AntennaNote::AntennaId)
.if_not_exists()
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("IDX_bd0397be22147e17210940e125")
.table(AntennaNote::Table)
.col(AntennaNote::NoteId)
.if_not_exists()
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("IDX_335a0bf3f904406f9ef3dd51c2")
.table(AntennaNote::Table)
.col(AntennaNote::NoteId)
.col(AntennaNote::AntennaId)
.unique()
.if_not_exists()
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("IDX_9937ea48d7ae97ffb4f3f063a4")
.table(AntennaNote::Table)
.col(AntennaNote::Read)
.if_not_exists()
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("FK_0d775946662d2575dfd2068a5f5")
.from(AntennaNote::Table, AntennaNote::AntennaId)
.to(Antenna::Table, Antenna::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("FK_bd0397be22147e17210940e125b")
.from(AntennaNote::Table, AntennaNote::NoteId)
.to(Note::Table, Note::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
Ok(())
}
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum AntennaNote {
Table,
Id,
#[iden = "noteId"]
NoteId,
#[iden = "antennaId"]
AntennaId,
Read,
}
#[derive(Iden)]
enum Antenna {
Table,
Id,
}
#[derive(Iden)]
enum Note {
Table,
Id,
}

View file

@ -5,6 +5,10 @@ use urlencoding::encode;
use sea_orm_migration::prelude::*; use sea_orm_migration::prelude::*;
const DB_URL_ENV: &str = "DATABASE_URL";
const CACHE_URL_ENV: &str = "CACHE_URL";
const CACHE_PREFIX_ENV: &str = "CACHE_PREFIX";
#[cfg(feature = "convert")] #[cfg(feature = "convert")]
mod vec_to_json; mod vec_to_json;
@ -15,8 +19,9 @@ async fn main() {
.expect("Failed to open '.config/default.yml'"); .expect("Failed to open '.config/default.yml'");
let config: Config = serde_yaml::from_reader(yml).expect("Failed to parse yaml"); let config: Config = serde_yaml::from_reader(yml).expect("Failed to parse yaml");
if env::var_os(DB_URL_ENV).is_none() {
env::set_var( env::set_var(
"DATABASE_URL", DB_URL_ENV,
format!( format!(
"postgres://{}:{}@{}:{}/{}", "postgres://{}:{}@{}:{}/{}",
config.db.user, config.db.user,
@ -26,6 +31,36 @@ async fn main() {
config.db.db, config.db.db,
), ),
); );
};
if env::var_os(CACHE_URL_ENV).is_none() {
let redis_conf = match config.cache_server {
None => config.redis,
Some(conf) => conf,
};
let redis_proto = match redis_conf.tls {
None => "redis",
Some(_) => "rediss",
};
let redis_uri_userpass = match redis_conf.user {
None => "".to_string(),
Some(user) => format!("{}:{}@", user, encode(&redis_conf.pass.unwrap_or_default())),
};
let redis_uri_hostport = format!("{}:{}", redis_conf.host, redis_conf.port);
let redis_uri = format!(
"{}://{}{}/{}",
redis_proto, redis_uri_userpass, redis_uri_hostport, redis_conf.db
);
env::set_var(CACHE_URL_ENV, redis_uri);
env::set_var(
CACHE_PREFIX_ENV,
if redis_conf.prefix.is_empty() {
config.url.host_str().unwrap()
} else {
&redis_conf.prefix
},
);
}
cli::run_cli(migration::Migrator).await; cli::run_cli(migration::Migrator).await;
@ -34,13 +69,15 @@ async fn main() {
} }
#[derive(Debug, PartialEq, Deserialize)] #[derive(Debug, PartialEq, Deserialize)]
#[serde(rename = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Config { pub struct Config {
pub url: url::Url,
pub db: DbConfig, pub db: DbConfig,
pub redis: RedisConfig,
pub cache_server: Option<RedisConfig>,
} }
#[derive(Debug, PartialEq, Deserialize)] #[derive(Debug, PartialEq, Deserialize)]
#[serde(rename = "camelCase")]
pub struct DbConfig { pub struct DbConfig {
pub host: String, pub host: String,
pub port: u32, pub port: u32,
@ -48,3 +85,23 @@ pub struct DbConfig {
pub user: String, pub user: String,
pub pass: String, pub pass: String,
} }
#[derive(Debug, PartialEq, Deserialize)]
pub struct RedisConfig {
pub host: String,
pub port: u32,
pub user: Option<String>,
pub pass: Option<String>,
pub tls: Option<TlsConfig>,
#[serde(default)]
pub db: u32,
#[serde(default)]
pub prefix: String,
}
#[derive(Debug, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TlsConfig {
pub host: String,
pub reject_unauthorized: bool,
}

View file

@ -43,6 +43,7 @@
"universal": "napi universal", "universal": "napi universal",
"version": "napi version", "version": "napi version",
"format": "cargo fmt --all", "format": "cargo fmt --all",
"lint": "cargo clippy --fix",
"cargo:test": "pnpm run cargo:unit && pnpm run cargo:integration", "cargo:test": "pnpm run cargo:unit && pnpm run cargo:integration",
"cargo:unit": "cargo test unit_test && cargo test -F napi unit_test", "cargo:unit": "cargo test unit_test && cargo test -F napi unit_test",
"cargo:integration": "cargo test -F noarray int_test -- --test-threads=1" "cargo:integration": "cargo test -F noarray int_test -- --test-threads=1"

View file

@ -8,7 +8,6 @@ pub mod ad;
pub mod announcement; pub mod announcement;
pub mod announcement_read; pub mod announcement_read;
pub mod antenna; pub mod antenna;
pub mod antenna_note;
pub mod app; pub mod app;
pub mod attestation_challenge; pub mod attestation_challenge;
pub mod auth_session; pub mod auth_session;

View file

@ -15,6 +15,10 @@ pub struct Model {
pub image_url: Option<String>, pub image_url: Option<String>,
#[sea_orm(column_name = "updatedAt")] #[sea_orm(column_name = "updatedAt")]
pub updated_at: Option<DateTimeWithTimeZone>, pub updated_at: Option<DateTimeWithTimeZone>,
#[sea_orm(column_name = "showPopup")]
pub show_popup: bool,
#[sea_orm(column_name = "isGoodNews")]
pub is_good_news: bool,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -37,8 +37,6 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation { pub enum Relation {
#[sea_orm(has_many = "super::antenna_note::Entity")]
AntennaNote,
#[sea_orm( #[sea_orm(
belongs_to = "super::user::Entity", belongs_to = "super::user::Entity",
from = "Column::UserId", from = "Column::UserId",
@ -65,12 +63,6 @@ pub enum Relation {
UserList, UserList,
} }
impl Related<super::antenna_note::Entity> for Entity {
fn to() -> RelationDef {
Relation::AntennaNote.def()
}
}
impl Related<super::user::Entity> for Entity { impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef { fn to() -> RelationDef {
Relation::User.def() Relation::User.def()

View file

@ -189,6 +189,10 @@ pub struct Model {
pub silenced_hosts: StringVec, pub silenced_hosts: StringVec,
#[sea_orm(column_name = "experimentalFeatures", column_type = "JsonBinary")] #[sea_orm(column_name = "experimentalFeatures", column_type = "JsonBinary")]
pub experimental_features: Json, pub experimental_features: Json,
#[sea_orm(column_name = "enableServerMachineStats")]
pub enable_server_machine_stats: bool,
#[sea_orm(column_name = "enableIdenticonGeneration")]
pub enable_identicon_generation: bool,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -67,8 +67,6 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation { pub enum Relation {
#[sea_orm(has_many = "super::antenna_note::Entity")]
AntennaNote,
#[sea_orm( #[sea_orm(
belongs_to = "super::channel::Entity", belongs_to = "super::channel::Entity",
from = "Column::ChannelId", from = "Column::ChannelId",
@ -131,12 +129,6 @@ pub enum Relation {
UserNotePining, UserNotePining,
} }
impl Related<super::antenna_note::Entity> for Entity {
fn to() -> RelationDef {
Relation::AntennaNote.def()
}
}
impl Related<super::channel::Entity> for Entity { impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef { fn to() -> RelationDef {
Relation::Channel.def() Relation::Channel.def()

View file

@ -6,7 +6,6 @@ pub use super::ad::Entity as Ad;
pub use super::announcement::Entity as Announcement; pub use super::announcement::Entity as Announcement;
pub use super::announcement_read::Entity as AnnouncementRead; pub use super::announcement_read::Entity as AnnouncementRead;
pub use super::antenna::Entity as Antenna; pub use super::antenna::Entity as Antenna;
pub use super::antenna_note::Entity as AntennaNote;
pub use super::app::Entity as App; pub use super::app::Entity as App;
pub use super::attestation_challenge::Entity as AttestationChallenge; pub use super::attestation_challenge::Entity as AttestationChallenge;
pub use super::auth_session::Entity as AuthSession; pub use super::auth_session::Entity as AuthSession;

View file

@ -1,9 +1,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use cfg_if::cfg_if; use cfg_if::cfg_if;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sea_orm::EntityTrait;
use crate::database; use crate::database;
use crate::model::entity::{antenna, antenna_note, user_group_joining}; use crate::model::entity::{antenna, user_group_joining};
use crate::model::error::Error; use crate::model::error::Error;
use crate::model::schema::Antenna; use crate::model::schema::Antenna;
@ -14,12 +14,6 @@ use super::Repository;
impl Repository<Antenna> for antenna::Model { impl Repository<Antenna> for antenna::Model {
async fn pack(self) -> Result<Antenna, Error> { async fn pack(self) -> Result<Antenna, Error> {
let db = database::get_database()?; let db = database::get_database()?;
let has_unread_note = antenna_note::Entity::find()
.filter(antenna_note::Column::AntennaId.eq(self.id.to_owned()))
.filter(antenna_note::Column::Read.eq(false))
.one(db)
.await?
.is_some();
let user_group_joining = match self.user_group_joining_id { let user_group_joining = match self.user_group_joining_id {
None => None, None => None,
Some(id) => user_group_joining::Entity::find_by_id(id).one(db).await?, Some(id) => user_group_joining::Entity::find_by_id(id).one(db).await?,
@ -46,13 +40,13 @@ impl Repository<Antenna> for antenna::Model {
src: self.src.try_into()?, src: self.src.try_into()?,
user_list_id: self.user_list_id, user_list_id: self.user_list_id,
user_group_id, user_group_id,
users: self.users.into(), users: self.users,
instances: self.instances.into(), instances: self.instances.into(),
case_sensitive: self.case_sensitive, case_sensitive: self.case_sensitive,
notify: self.notify, notify: self.notify,
with_replies: self.with_replies, with_replies: self.with_replies,
with_file: self.with_file, with_file: self.with_file,
has_unread_note, has_unread_note: false,
}) })
} }

View file

@ -58,7 +58,7 @@ impl TryFrom<AntennaSrcEnum> for super::AntennaSrc {
// ---- TODO: could be macro // ---- TODO: could be macro
impl Schema<Self> for super::Antenna {} impl Schema<Self> for super::Antenna {}
pub static VALIDATOR: Lazy<JSONSchema> = Lazy::new(|| super::Antenna::validator()); pub static VALIDATOR: Lazy<JSONSchema> = Lazy::new(super::Antenna::validator);
// ---- // ----
cfg_if! { cfg_if! {

View file

@ -91,7 +91,7 @@ pub enum AppPermission {
impl Schema<Self> for App {} impl Schema<Self> for App {}
pub static VALIDATOR: Lazy<JSONSchema> = Lazy::new(|| App::validator()); pub static VALIDATOR: Lazy<JSONSchema> = Lazy::new(App::validator);
#[cfg(test)] #[cfg(test)]
mod unit_test { mod unit_test {
@ -105,9 +105,9 @@ mod unit_test {
#[test] #[test]
fn app_valid() { fn app_valid() {
init_id(12, ""); init_id(16, "");
let instance = json!({ let instance = json!({
"id": create_id().unwrap(), "id": create_id(0).unwrap(),
"name": "Test App", "name": "Test App",
"secret": gen_string(24), "secret": gen_string(24),
"callbackUrl": "urn:ietf:wg:oauth:2.0:oob", "callbackUrl": "urn:ietf:wg:oauth:2.0:oob",
@ -119,9 +119,9 @@ mod unit_test {
#[test] #[test]
fn app_invalid() { fn app_invalid() {
init_id(12, ""); init_id(16, "");
let instance = json!({ let instance = json!({
"id": create_id().unwrap(), "id": create_id(0).unwrap(),
// "name" is required // "name" is required
"name": null, "name": null,
// "permission" must be one of the app permissions // "permission" must be one of the app permissions

View file

@ -1,7 +1,10 @@
//! ID generation utility based on [cuid2] //! ID generation utility based on [cuid2]
use cfg_if::cfg_if; use cfg_if::cfg_if;
use chrono::Utc;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use radix_fmt::radix_36;
use std::cmp;
use crate::impl_into_napi_error; use crate::impl_into_napi_error;
@ -14,47 +17,56 @@ impl_into_napi_error!(ErrorUninitialized);
static FINGERPRINT: OnceCell<String> = OnceCell::new(); static FINGERPRINT: OnceCell<String> = OnceCell::new();
static GENERATOR: OnceCell<cuid2::CuidConstructor> = OnceCell::new(); static GENERATOR: OnceCell<cuid2::CuidConstructor> = OnceCell::new();
const TIME_2000: i64 = 946_684_800_000;
const TIMESTAMP_LENGTH: u16 = 8;
/// Initializes Cuid2 generator. Must be called before any [create_id]. /// Initializes Cuid2 generator. Must be called before any [create_id].
pub fn init_id(length: u16, fingerprint: impl Into<String>) { pub fn init_id<'a>(length: u16, fingerprint: &'a str) {
FINGERPRINT.get_or_init(move || format!("{}{}", fingerprint.into(), cuid2::create_id())); FINGERPRINT.get_or_init(move || format!("{}{}", fingerprint, cuid2::create_id()));
GENERATOR.get_or_init(move || { GENERATOR.get_or_init(move || {
cuid2::CuidConstructor::new() cuid2::CuidConstructor::new()
.with_length(length) // length to pass shoule be greater than or equal to 8.
.with_length(cmp::max(length - TIMESTAMP_LENGTH, 8))
.with_fingerprinter(|| FINGERPRINT.get().unwrap().clone()) .with_fingerprinter(|| FINGERPRINT.get().unwrap().clone())
}); });
} }
/// Returns Cuid2 with the length specified by [init_id]. Must be called after /// Returns Cuid2 with the length specified by [init_id]. Must be called after
/// [init_id], otherwise returns [ErrorUninitialized]. /// [init_id], otherwise returns [ErrorUninitialized].
pub fn create_id() -> Result<String, ErrorUninitialized> { /// The current timestamp via [chrono::Utc] is used if `date_num` is `0`.
pub fn create_id(date_num: i64) -> Result<String, ErrorUninitialized> {
match GENERATOR.get() { match GENERATOR.get() {
None => Err(ErrorUninitialized), None => Err(ErrorUninitialized),
Some(gen) => Ok(gen.create_id()), Some(gen) => {
let date_num = if date_num > 0 {
date_num
} else {
Utc::now().timestamp_millis()
};
let time = cmp::max(date_num - TIME_2000, 0);
Ok(format!(
"{:0>8}{}",
radix_36(time).to_string(),
gen.create_id()
))
}
} }
} }
cfg_if! { cfg_if! {
if #[cfg(feature = "napi")] { if #[cfg(feature = "napi")] {
use radix_fmt::radix_36;
use std::cmp;
use napi::bindgen_prelude::BigInt;
use napi_derive::napi; use napi_derive::napi;
const TIME_2000: u64 = 946_684_800_000;
const TIMESTAMP_LENGTH: u16 = 8;
/// Calls [init_id] inside. Must be called before [native_create_id]. /// Calls [init_id] inside. Must be called before [native_create_id].
#[napi] #[napi]
pub fn native_init_id_generator(length: u16, fingerprint: String) { pub fn native_init_id_generator(length: u16, fingerprint: String) {
// length to pass init_id shoule be greater than or equal to 8. init_id(length, &fingerprint);
init_id(cmp::max(length - TIMESTAMP_LENGTH, 8), fingerprint);
} }
/// Generates /// Generates
#[napi] #[napi]
pub fn native_create_id(date_num: BigInt) -> String { pub fn native_create_id(date_num: i64) -> String {
let time = cmp::max(date_num.get_u64().1 - TIME_2000, 0); create_id(date_num).unwrap()
format!("{:0>8}{}", radix_36(time).to_string(), create_id().unwrap())
} }
} }
} }
@ -62,37 +74,17 @@ cfg_if! {
#[cfg(test)] #[cfg(test)]
mod unit_test { mod unit_test {
use crate::util::id; use crate::util::id;
use cfg_if::cfg_if;
use pretty_assertions::{assert_eq, assert_ne}; use pretty_assertions::{assert_eq, assert_ne};
use std::thread; use std::thread;
cfg_if! {
if #[cfg(feature = "napi")] {
use chrono::Utc;
#[test]
fn can_generate_aid_compat_ids() {
id::native_init_id_generator(20, "".to_string());
let id1 = id::native_create_id(Utc::now().timestamp_millis().into());
assert_eq!(id1.len(), 20);
let id1 = id::native_create_id(Utc::now().timestamp_millis().into());
let id2 = id::native_create_id(Utc::now().timestamp_millis().into());
assert_ne!(id1, id2);
let id1 = thread::spawn(|| id::native_create_id(Utc::now().timestamp_millis().into()));
let id2 = thread::spawn(|| id::native_create_id(Utc::now().timestamp_millis().into()));
assert_ne!(id1.join().unwrap(), id2.join().unwrap());
}
} else {
#[test] #[test]
fn can_generate_unique_ids() { fn can_generate_unique_ids() {
assert_eq!(id::create_id(), Err(id::ErrorUninitialized)); assert_eq!(id::create_id(0), Err(id::ErrorUninitialized));
id::init_id(12, ""); id::init_id(16, "");
assert_eq!(id::create_id().unwrap().len(), 12); assert_eq!(id::create_id(0).unwrap().len(), 16);
assert_ne!(id::create_id().unwrap(), id::create_id().unwrap()); assert_ne!(id::create_id(0).unwrap(), id::create_id(0).unwrap());
let id1 = thread::spawn(|| id::create_id().unwrap()); let id1 = thread::spawn(|| id::create_id(0).unwrap());
let id2 = thread::spawn(|| id::create_id().unwrap()); let id2 = thread::spawn(|| id::create_id(0).unwrap());
assert_ne!(id1.join().unwrap(), id2.join().unwrap()); assert_ne!(id1.join().unwrap(), id2.join().unwrap());
} }
}
}
} }

View file

@ -139,17 +139,17 @@ async fn cleanup() {
} }
async fn setup_model(db: &DbConn) { async fn setup_model(db: &DbConn) {
init_id(12, ""); init_id(16, "");
db.transaction::<_, (), DbErr>(|txn| { db.transaction::<_, (), DbErr>(|txn| {
Box::pin(async move { Box::pin(async move {
let user_id = create_id().unwrap(); let user_id = create_id(0).unwrap();
let name = "Alice"; let name = "Alice";
let user_model = entity::user::Model { let user_model = entity::user::Model {
id: user_id.to_owned(), id: user_id.to_owned(),
created_at: Utc::now().into(), created_at: Utc::now().into(),
username: name.to_lowercase().to_string(), username: name.to_lowercase(),
username_lower: name.to_lowercase().to_string(), username_lower: name.to_lowercase(),
name: Some(name.to_string()), name: Some(name.to_string()),
token: Some(gen_string(16)), token: Some(gen_string(16)),
is_admin: true, is_admin: true,
@ -161,7 +161,7 @@ async fn setup_model(db: &DbConn) {
.insert(txn) .insert(txn)
.await?; .await?;
let antenna_model = entity::antenna::Model { let antenna_model = entity::antenna::Model {
id: create_id().unwrap(), id: create_id(0).unwrap(),
created_at: Utc::now().into(), created_at: Utc::now().into(),
user_id: user_id.to_owned(), user_id: user_id.to_owned(),
name: "Alice Antenna".to_string(), name: "Alice Antenna".to_string(),
@ -186,7 +186,7 @@ async fn setup_model(db: &DbConn) {
.insert(txn) .insert(txn)
.await?; .await?;
let note_model = entity::note::Model { let note_model = entity::note::Model {
id: create_id().unwrap(), id: create_id(0).unwrap(),
created_at: Utc::now().into(), created_at: Utc::now().into(),
text: Some("Testing 123".to_string()), text: Some("Testing 123".to_string()),
user_id: user_id.to_owned(), user_id: user_id.to_owned(),

View file

@ -43,18 +43,16 @@ mod int_test {
keywords: vec![ keywords: vec![
vec!["foo".to_string(), "bar".to_string()], vec!["foo".to_string(), "bar".to_string()],
vec!["foobar".to_string()], vec!["foobar".to_string()],
] ],
.into(),
exclude_keywords: vec![ exclude_keywords: vec![
vec!["abc".to_string()], vec!["abc".to_string()],
vec!["def".to_string(), "ghi".to_string()], vec!["def".to_string(), "ghi".to_string()],
] ],
.into(),
src: schema::AntennaSrc::All, src: schema::AntennaSrc::All,
user_list_id: None, user_list_id: None,
user_group_id: None, user_group_id: None,
users: vec![].into(), users: vec![],
instances: vec![].into(), instances: vec![],
case_sensitive: true, case_sensitive: true,
notify: true, notify: true,
with_replies: false, with_replies: false,
@ -95,7 +93,7 @@ mod int_test {
.unwrap() .unwrap()
.expect("note not found"); .expect("note not found");
let antenna_note = antenna_note::Model { let antenna_note = antenna_note::Model {
id: util::id::create_id().unwrap(), id: util::id::create_id(0).unwrap(),
antenna_id: alice_antenna.id.to_owned(), antenna_id: alice_antenna.id.to_owned(),
note_id: note_model.id.to_owned(), note_id: note_model.id.to_owned(),
read: false, read: false,

View file

@ -59,18 +59,21 @@
"color-convert": "2.0.1", "color-convert": "2.0.1",
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"decompress": "^4.2.1",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1", "escape-regexp": "0.0.1",
"feed": "4.2.2", "feed": "4.2.2",
"file-type": "17.1.6", "file-type": "17.1.6",
"fluent-ffmpeg": "2.1.2", "fluent-ffmpeg": "2.1.2",
"got": "12.5.3", "got": "12.5.3",
"gunzip-maybe": "^1.4.2",
"hpagent": "0.1.2", "hpagent": "0.1.2",
"ioredis": "5.3.2", "ioredis": "5.3.2",
"ip-cidr": "3.1.0", "ip-cidr": "3.1.0",
"is-svg": "4.3.2", "is-svg": "4.3.2",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "20.0.3", "jsdom": "20.0.3",
"json5": "2.2.3",
"jsonld": "8.2.0", "jsonld": "8.2.0",
"jsrsasign": "10.8.6", "jsrsasign": "10.8.6",
"koa": "2.14.2", "koa": "2.14.2",
@ -125,6 +128,7 @@
"summaly": "2.7.0", "summaly": "2.7.0",
"syslog-pro": "1.0.0", "syslog-pro": "1.0.0",
"systeminformation": "5.17.17", "systeminformation": "5.17.17",
"tar-stream": "^3.1.6",
"tesseract.js": "^3.0.3", "tesseract.js": "^3.0.3",
"tinycolor2": "1.5.2", "tinycolor2": "1.5.2",
"tmp": "0.2.1", "tmp": "0.2.1",
@ -185,7 +189,6 @@
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "^8.44.0", "eslint": "^8.44.0",
"execa": "6.1.0", "execa": "6.1.0",
"json5": "2.2.3",
"json5-loader": "4.0.1", "json5-loader": "4.0.1",
"mocha": "10.2.0", "mocha": "10.2.0",
"pug": "3.0.2", "pug": "3.0.2",

View file

@ -54,9 +54,9 @@ export default function load() {
mixin.userAgent = `Calckey/${meta.version} (${config.url})`; mixin.userAgent = `Calckey/${meta.version} (${config.url})`;
mixin.clientEntry = clientManifest["src/init.ts"]; mixin.clientEntry = clientManifest["src/init.ts"];
if (!config.redis.prefix) config.redis.prefix = mixin.host; if (!config.redis.prefix) config.redis.prefix = mixin.hostname;
if (config.cacheServer && !config.cacheServer.prefix) if (config.cacheServer && !config.cacheServer.prefix)
config.cacheServer.prefix = mixin.host; config.cacheServer.prefix = mixin.hostname;
return Object.assign(config, mixin); return Object.assign(config, mixin);
} }

View file

@ -1,7 +1,13 @@
import config from "@/config/index.js"; import config from "@/config/index.js";
import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js"; import {
DB_MAX_NOTE_TEXT_LENGTH,
DB_MAX_IMAGE_COMMENT_LENGTH,
} from "@/misc/hard-limits.js";
export const MAX_NOTE_TEXT_LENGTH = config.maxNoteLength ?? 3000; export const MAX_NOTE_TEXT_LENGTH = Math.min(
config.maxNoteLength ?? 3000,
DB_MAX_NOTE_TEXT_LENGTH,
);
export const MAX_CAPTION_TEXT_LENGTH = Math.min( export const MAX_CAPTION_TEXT_LENGTH = Math.min(
config.maxCaptionLength ?? 1500, config.maxCaptionLength ?? 1500,
DB_MAX_IMAGE_COMMENT_LENGTH, DB_MAX_IMAGE_COMMENT_LENGTH,

View file

@ -58,7 +58,6 @@ import { AnnouncementRead } from "@/models/entities/announcement-read.js";
import { Clip } from "@/models/entities/clip.js"; import { Clip } from "@/models/entities/clip.js";
import { ClipNote } from "@/models/entities/clip-note.js"; import { ClipNote } from "@/models/entities/clip-note.js";
import { Antenna } from "@/models/entities/antenna.js"; import { Antenna } from "@/models/entities/antenna.js";
import { AntennaNote } from "@/models/entities/antenna-note.js";
import { PromoNote } from "@/models/entities/promo-note.js"; import { PromoNote } from "@/models/entities/promo-note.js";
import { PromoRead } from "@/models/entities/promo-read.js"; import { PromoRead } from "@/models/entities/promo-read.js";
import { Relay } from "@/models/entities/relay.js"; import { Relay } from "@/models/entities/relay.js";
@ -168,7 +167,6 @@ export const entities = [
Clip, Clip,
ClipNote, ClipNote,
Antenna, Antenna,
AntennaNote,
PromoNote, PromoNote,
PromoRead, PromoRead,
Relay, Relay,

View file

@ -24,6 +24,7 @@ export async function downloadUrl(url: string, path: string): Promise<void> {
.stream(url, { .stream(url, {
headers: { headers: {
"User-Agent": config.userAgent, "User-Agent": config.userAgent,
Host: new URL(url).hostname,
}, },
timeout: { timeout: {
lookup: timeout, lookup: timeout,

View file

@ -17,5 +17,5 @@ nativeInitIdGenerator(length, fingerprint);
* Ref: https://github.com/paralleldrive/cuid2#parameterized-length * Ref: https://github.com/paralleldrive/cuid2#parameterized-length
*/ */
export function genId(date?: Date): string { export function genId(date?: Date): string {
return nativeCreateId(BigInt((date ?? new Date()).getTime())); return nativeCreateId((date ?? new Date()).getTime());
} }

View file

@ -3,9 +3,13 @@
/** /**
* Maximum note text length that can be stored in DB. * Maximum note text length that can be stored in DB.
* Surrogate pairs count as one * Surrogate pairs count as one
* DEPRECARTED: use const/MAX_NOTE_TEXT_LENGTH instead *
* NOTE: this can hypothetically be pushed further
* (up to 250000000), but will likely cause truncations
* and incompatibilities with other servers,
* as well as potential performance issues.
*/ */
// export const DB_MAX_NOTE_TEXT_LENGTH = 8192; export const DB_MAX_NOTE_TEXT_LENGTH = 100000;
/** /**
* Maximum image description length that can be stored in DB. * Maximum image description length that can be stored in DB.

View file

@ -0,0 +1,136 @@
import * as fs from "node:fs";
import Logger from "@/services/logger.js";
import { createTemp, createTempDir } from "./create-temp.js";
import { downloadUrl } from "./download-url.js";
import { addFile } from "@/services/drive/add-file.js";
import { Users } from "@/models/index.js";
import * as tar from "tar-stream";
import gunzip from "gunzip-maybe";
import decompress from "decompress";
import * as Path from "node:path";
const logger = new Logger("process-masto-notes");
export async function processMastoNotes(
fn: string,
url: string,
uid: string,
): Promise<any> {
// Create temp file
const [path, cleanup] = await createTemp();
const [unzipPath, unzipCleanup] = await createTempDir();
logger.info(`Temp file is ${path}`);
try {
// write content at URL to temp file
await downloadUrl(url, path);
return await processMastoFile(fn, path, unzipPath, uid);
} finally {
cleanup();
//unzipCleanup();
}
}
function processMastoFile(fn: string, path: string, dir: string, uid: string) {
return new Promise(async (resolve, reject) => {
const user = await Users.findOneBy({ id: uid });
try {
logger.info(`Start unzip ${path}`);
fn.endsWith("tar.gz")
? await unzipTarGz(path, dir)
: await unzipZip(path, dir);
logger.info(`Unzip to ${dir}`);
const outbox = JSON.parse(fs.readFileSync(`${dir}/outbox.json`));
for (const note of outbox.orderedItems) {
for (const attachment of note.object.attachment) {
const url = attachment.url.replaceAll("..", "");
if (url.indexOf('\0') !== -1) {
logger.error(`Found Poison Null Bytes Attack: ${url}`);
reject();
return;
}
try {
const fpath = Path.resolve(`${dir}${url}`);
if (!fpath.startsWith(dir)) {
logger.error(`Found Path Attack: ${url}`);
reject();
return;
}
logger.info(fpath);
const driveFile = await addFile({ user: user, path: fpath });
attachment.driveFile = driveFile;
} catch (e) {
logger.error(`Skipped adding file to drive: ${url}`);
}
}
}
resolve(outbox);
} catch (e) {
logger.error(`Error on extract masto note package: ${fn}`);
reject(e);
}
});
}
function createFileDir(fn: string) {
if (!fs.existsSync(fn)) {
fs.mkdirSync(fn, { recursive: true });
fs.rmdirSync(fn);
}
}
function unzipZip(fn: string, dir: string) {
return new Promise(async (resolve, reject) => {
try {
decompress(fn, dir).then((files: any) => {
resolve(files);
});
} catch (e) {
reject();
}
});
}
function unzipTarGz(fn: string, dir: string) {
return new Promise(async (resolve, reject) => {
const onErr = (err: any) => {
logger.error(`pipe broken: ${err}`);
reject();
};
try {
const extract = tar.extract().on("error", onErr);
dir = dir.endsWith("/") ? dir : dir + "/";
const ls: string[] = [];
extract.on("entry", function (header: any, stream: any, next: any) {
try {
ls.push(dir + header.name);
createFileDir(dir + header.name);
stream
.on("error", onErr)
.pipe(fs.createWriteStream(dir + header.name))
.on("error", onErr);
next();
} catch (e) {
logger.error(`create dir error:${e}`);
reject();
}
});
extract.on("finish", function () {
resolve(ls);
});
fs.createReadStream(fn)
.on("error", onErr)
.pipe(gunzip())
.on("error", onErr)
.pipe(extract)
.on("error", onErr);
} catch (e) {
logger.error(`unzipTarGz error: ${e}`);
reject();
}
});
}

View file

@ -1,50 +0,0 @@
import {
Entity,
Index,
JoinColumn,
Column,
ManyToOne,
PrimaryColumn,
} from "typeorm";
import { Note } from "./note.js";
import { Antenna } from "./antenna.js";
import { id } from "../id.js";
@Entity()
@Index(["noteId", "antennaId"], { unique: true })
export class AntennaNote {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
comment: "The note ID.",
})
public noteId: Note["id"];
@ManyToOne((type) => Note, {
onDelete: "CASCADE",
})
@JoinColumn()
public note: Note | null;
@Index()
@Column({
...id(),
comment: "The antenna ID.",
})
public antennaId: Antenna["id"];
@ManyToOne((type) => Antenna, {
onDelete: "CASCADE",
})
@JoinColumn()
public antenna: Antenna | null;
@Index()
@Column("boolean", {
default: false,
})
public read: boolean;
}

View file

@ -326,13 +326,13 @@ export class Meta {
public smtpPort: number | null; public smtpPort: number | null;
@Column("varchar", { @Column("varchar", {
length: 128, length: 1024,
nullable: true, nullable: true,
}) })
public smtpUser: string | null; public smtpUser: string | null;
@Column("varchar", { @Column("varchar", {
length: 128, length: 1024,
nullable: true, nullable: true,
}) })
public smtpPass: string | null; public smtpPass: string | null;

View file

@ -51,6 +51,7 @@ export class UserProfile {
public fields: { public fields: {
name: string; name: string;
value: string; value: string;
verified?: boolean;
}[]; }[];
@Column("varchar", { @Column("varchar", {

View file

@ -51,7 +51,6 @@ import { UsedUsername } from "./entities/used-username.js";
import { ClipRepository } from "./repositories/clip.js"; import { ClipRepository } from "./repositories/clip.js";
import { ClipNote } from "./entities/clip-note.js"; import { ClipNote } from "./entities/clip-note.js";
import { AntennaRepository } from "./repositories/antenna.js"; import { AntennaRepository } from "./repositories/antenna.js";
import { AntennaNote } from "./entities/antenna-note.js";
import { PromoNote } from "./entities/promo-note.js"; import { PromoNote } from "./entities/promo-note.js";
import { PromoRead } from "./entities/promo-read.js"; import { PromoRead } from "./entities/promo-read.js";
import { EmojiRepository } from "./repositories/emoji.js"; import { EmojiRepository } from "./repositories/emoji.js";
@ -123,7 +122,6 @@ export const ModerationLogs = ModerationLogRepository;
export const Clips = ClipRepository; export const Clips = ClipRepository;
export const ClipNotes = db.getRepository(ClipNote); export const ClipNotes = db.getRepository(ClipNote);
export const Antennas = AntennaRepository; export const Antennas = AntennaRepository;
export const AntennaNotes = db.getRepository(AntennaNote);
export const PromoNotes = db.getRepository(PromoNote); export const PromoNotes = db.getRepository(PromoNote);
export const PromoReads = db.getRepository(PromoRead); export const PromoReads = db.getRepository(PromoRead);
export const Relays = RelayRepository; export const Relays = RelayRepository;

View file

@ -18,7 +18,6 @@ import { createPerson } from "@/remote/activitypub/models/person.js";
import { import {
AnnouncementReads, AnnouncementReads,
Announcements, Announcements,
AntennaNotes,
Blockings, Blockings,
ChannelFollowings, ChannelFollowings,
DriveFiles, DriveFiles,
@ -258,23 +257,24 @@ export const UserRepository = db.getRepository(User).extend({
}, },
async getHasUnreadAntenna(userId: User["id"]): Promise<boolean> { async getHasUnreadAntenna(userId: User["id"]): Promise<boolean> {
try { // try {
const myAntennas = (await getAntennas()).filter( // const myAntennas = (await getAntennas()).filter(
(a) => a.userId === userId, // (a) => a.userId === userId,
); // );
const unread = // const unread =
myAntennas.length > 0 // myAntennas.length > 0
? await AntennaNotes.findOneBy({ // ? await AntennaNotes.findOneBy({
antennaId: In(myAntennas.map((x) => x.id)), // antennaId: In(myAntennas.map((x) => x.id)),
read: false, // read: false,
}) // })
: null; // : null;
return unread != null; // return unread != null;
} catch (e) { // } catch (e) {
return false; // return false;
} // }
return false; // TODO
}, },
async getHasUnreadChannel(userId: User["id"]): Promise<boolean> { async getHasUnreadChannel(userId: User["id"]): Promise<boolean> {

View file

@ -576,6 +576,16 @@ export default function () {
{ removeOnComplete: true, removeOnFail: true }, { removeOnComplete: true, removeOnFail: true },
); );
systemQueue.add(
"verifyLinks",
{},
{
repeat: { cron: "0 0 * * 0" },
removeOnComplete: true,
removeOnFail: true,
},
);
processSystemQueue(systemQueue); processSystemQueue(systemQueue);
} }

View file

@ -4,7 +4,7 @@ import * as fs from "node:fs";
import { queueLogger } from "../../logger.js"; import { queueLogger } from "../../logger.js";
import { addFile } from "@/services/drive/add-file.js"; import { addFile } from "@/services/drive/add-file.js";
import { format as dateFormat } from "date-fns"; import { format as dateFormat } from "date-fns";
import { Users, Notes, Polls } from "@/models/index.js"; import { Users, Notes, Polls, DriveFiles } from "@/models/index.js";
import { MoreThan } from "typeorm"; import { MoreThan } from "typeorm";
import type { Note } from "@/models/entities/note.js"; import type { Note } from "@/models/entities/note.js";
import type { Poll } from "@/models/entities/poll.js"; import type { Poll } from "@/models/entities/poll.js";
@ -75,7 +75,7 @@ export async function exportNotes(
if (note.hasPoll) { if (note.hasPoll) {
poll = await Polls.findOneByOrFail({ noteId: note.id }); poll = await Polls.findOneByOrFail({ noteId: note.id });
} }
const content = JSON.stringify(serialize(note, poll)); const content = JSON.stringify(await serialize(note, poll));
const isFirst = exportedNotesCount === 0; const isFirst = exportedNotesCount === 0;
await write(isFirst ? content : ",\n" + content); await write(isFirst ? content : ",\n" + content);
exportedNotesCount++; exportedNotesCount++;
@ -112,15 +112,16 @@ export async function exportNotes(
done(); done();
} }
function serialize( async function serialize(
note: Note, note: Note,
poll: Poll | null = null, poll: Poll | null = null,
): Record<string, unknown> { ): Promise<Record<string, unknown>> {
return { return {
id: note.id, id: note.id,
text: note.text, text: note.text,
createdAt: note.createdAt, createdAt: note.createdAt,
fileIds: note.fileIds, fileIds: note.fileIds,
files: await DriveFiles.packMany(note.fileIds),
replyId: note.replyId, replyId: note.replyId,
renoteId: note.renoteId, renoteId: note.renoteId,
poll: poll, poll: poll,

View file

@ -3,6 +3,8 @@ import create from "@/services/note/create.js";
import { Users } from "@/models/index.js"; import { Users } from "@/models/index.js";
import type { DbUserImportMastoPostJobData } from "@/queue/types.js"; import type { DbUserImportMastoPostJobData } from "@/queue/types.js";
import { queueLogger } from "../../logger.js"; import { queueLogger } from "../../logger.js";
import { uploadFromUrl } from "@/services/drive/upload-from-url.js";
import type { DriveFile } from "@/models/entities/drive-file.js";
import type Bull from "bull"; import type Bull from "bull";
const logger = queueLogger.createSubLogger("import-calckey-post"); const logger = queueLogger.createSubLogger("import-calckey-post");
@ -29,10 +31,25 @@ export async function importCkPost(
done(); done();
return; return;
} }
const urls = (post.files || [])
.map((x: any) => x.url)
.filter((x: String) => x.startsWith("http"));
const files: DriveFile[] = [];
for (const url of urls) {
try {
const file = await uploadFromUrl({
url: url,
user: user,
});
files.push(file);
} catch (e) {
logger.error(`Skipped adding file to drive: ${url}`);
}
}
const { text, cw, localOnly, createdAt } = Post.parse(post); const { text, cw, localOnly, createdAt } = Post.parse(post);
const note = await create(user, { const note = await create(user, {
createdAt: createdAt, createdAt: createdAt,
files: undefined, files: files.length == 0 ? undefined : files,
poll: undefined, poll: undefined,
text: text || undefined, text: text || undefined,
reply: null, reply: null,

View file

@ -6,6 +6,8 @@ import type Bull from "bull";
import { htmlToMfm } from "@/remote/activitypub/misc/html-to-mfm.js"; import { htmlToMfm } from "@/remote/activitypub/misc/html-to-mfm.js";
import { resolveNote } from "@/remote/activitypub/models/note.js"; import { resolveNote } from "@/remote/activitypub/models/note.js";
import { Note } from "@/models/entities/note.js"; import { Note } from "@/models/entities/note.js";
import { uploadFromUrl } from "@/services/drive/upload-from-url.js";
import type { DriveFile } from "@/models/entities/drive-file.js";
const logger = queueLogger.createSubLogger("import-masto-post"); const logger = queueLogger.createSubLogger("import-masto-post");
@ -43,9 +45,32 @@ export async function importMastoPost(
throw e; throw e;
} }
job.progress(80); job.progress(80);
let files: DriveFile[] = (post.object.attachment || [])
.map((x: any) => x?.driveFile)
.filter((x: any) => x);
if (files.length == 0) {
const urls = post.object.attachment
.map((x: any) => x.url)
.filter((x: String) => x.startsWith("http"));
files = [];
for (const url of urls) {
try {
const file = await uploadFromUrl({
url: url,
user: user,
});
files.push(file);
} catch (e) {
logger.error(`Skipped adding file to drive: ${url}`);
}
}
}
const note = await create(user, { const note = await create(user, {
createdAt: new Date(post.object.published), createdAt: new Date(post.object.published),
files: undefined, files: files.length == 0 ? undefined : files,
poll: undefined, poll: undefined,
text: text || undefined, text: text || undefined,
reply, reply,

View file

@ -1,4 +1,5 @@
import { downloadTextFile } from "@/misc/download-text-file.js"; import { downloadTextFile } from "@/misc/download-text-file.js";
import { processMastoNotes } from "@/misc/process-masto-notes.js";
import { Users, DriveFiles } from "@/models/index.js"; import { Users, DriveFiles } from "@/models/index.js";
import type { DbUserImportPostsJobData } from "@/queue/types.js"; import type { DbUserImportPostsJobData } from "@/queue/types.js";
import { queueLogger } from "../../logger.js"; import { queueLogger } from "../../logger.js";
@ -30,6 +31,26 @@ export async function importPosts(
return; return;
} }
if (file.name.endsWith("tar.gz") || file.name.endsWith("zip")) {
try {
logger.info("Reading Mastodon archive");
const outbox = await processMastoNotes(
file.name,
file.url,
job.data.user.id,
);
for (const post of outbox.orderedItems) {
createImportMastoPostJob(job.data.user, post, job.data.signatureCheck);
}
} catch (e) {
// handle error
logger.warn(`Failed reading Mastodon archive: ${e}`);
}
logger.succ("Mastodon archive imported");
done();
return;
}
const json = await downloadTextFile(file.url); const json = await downloadTextFile(file.url);
try { try {

View file

@ -5,6 +5,7 @@ import { cleanCharts } from "./clean-charts.js";
import { checkExpiredMutings } from "./check-expired-mutings.js"; import { checkExpiredMutings } from "./check-expired-mutings.js";
import { clean } from "./clean.js"; import { clean } from "./clean.js";
import { setLocalEmojiSizes } from "./local-emoji-size.js"; import { setLocalEmojiSizes } from "./local-emoji-size.js";
import { verifyLinks } from "./verify-links.js";
const jobs = { const jobs = {
tickCharts, tickCharts,
@ -13,6 +14,7 @@ const jobs = {
checkExpiredMutings, checkExpiredMutings,
clean, clean,
setLocalEmojiSizes, setLocalEmojiSizes,
verifyLinks,
} as Record< } as Record<
string, string,
| Bull.ProcessCallbackFunction<Record<string, unknown>> | Bull.ProcessCallbackFunction<Record<string, unknown>>

View file

@ -0,0 +1,44 @@
import type Bull from "bull";
import { UserProfiles } from "@/models/index.js";
import { Not } from "typeorm";
import { queueLogger } from "../../logger.js";
import { verifyLink } from "@/services/fetch-rel-me.js";
import config from "@/config/index.js";
const logger = queueLogger.createSubLogger("verify-links");
export async function verifyLinks(
job: Bull.Job<Record<string, unknown>>,
done: any,
): Promise<void> {
logger.info("Verifying links...");
const usersToVerify = await UserProfiles.findBy({
fields: Not(null),
userHost: "",
});
for (const user of usersToVerify) {
for (const field of user.fields) {
if (!field || field.name === "" || field.value === "") {
continue;
}
if (field.value.startsWith("http") && user.user?.username) {
field.verified = await verifyLink(field.value, user.user.username);
}
}
if (user.fields.length > 0) {
try {
await UserProfiles.update(user.userId, {
fields: user.fields,
});
} catch (e) {
logger.error(`Failed to update user ${user.userId} ${e}`);
done(e);
}
}
}
logger.succ("All links successfully verified.");
done();
}

View file

@ -30,7 +30,7 @@ export const meta = {
id: "c3a5a51e-04d4-11ee-be56-0242ac120002", id: "c3a5a51e-04d4-11ee-be56-0242ac120002",
}, },
noKeywords: { noKeywords: {
message: "No keywords", message: "No keywords.",
code: "NO_KEYWORDS", code: "NO_KEYWORDS",
id: "aa975b74-1ddb-11ee-be56-0242ac120002", id: "aa975b74-1ddb-11ee-be56-0242ac120002",
}, },

View file

@ -1,7 +1,6 @@
import define from "../../define.js"; import define from "../../define.js";
import { Antennas, AntennaNotes } from "@/models/index.js"; import { Antennas } from "@/models/index.js";
import { FindOptionsWhere } from "typeorm"; import { FindOptionsWhere } from "typeorm";
import { AntennaNote } from "@/models/entities/antenna-note.js";
export const meta = { export const meta = {
tags: ["antennas", "account"], tags: ["antennas", "account"],
@ -29,15 +28,15 @@ export default define(meta, paramDef, async (ps, me) => {
return null; return null;
} }
await AntennaNotes.update( // await AntennaNotes.update(
{ // {
antennaId: antenna.id, // antennaId: antenna.id,
read: false, // read: false,
}, // },
{ // {
read: true, // read: true,
}, // },
); // );
return true; return true;
}); });

View file

@ -1,6 +1,8 @@
import define from "../../define.js"; import define from "../../define.js";
import readNote from "@/services/note/read.js"; import readNote from "@/services/note/read.js";
import { Antennas, Notes, AntennaNotes } from "@/models/index.js"; import { Antennas, Notes } from "@/models/index.js";
import { redisClient } from "@/db/redis.js";
import { genId } from "@/misc/gen-id.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js"; import { makePaginationQuery } from "../../common/make-pagination-query.js";
import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; import { generateVisibilityQuery } from "../../common/generate-visibility-query.js";
import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js";
@ -58,6 +60,26 @@ export default define(meta, paramDef, async (ps, user) => {
throw new ApiError(meta.errors.noSuchAntenna); throw new ApiError(meta.errors.noSuchAntenna);
} }
const noteIdsRes = await redisClient.xrevrange(
`antennaTimeline:${antenna.id}`,
ps.untilDate || "+",
"-",
"COUNT",
ps.limit + 1,
); // untilIdに指定したものも含まれるため+1
if (noteIdsRes.length === 0) {
return [];
}
const noteIds = noteIdsRes
.map((x) => x[1][1])
.filter((x) => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
const query = makePaginationQuery( const query = makePaginationQuery(
Notes.createQueryBuilder("note"), Notes.createQueryBuilder("note"),
ps.sinceId, ps.sinceId,
@ -65,11 +87,7 @@ export default define(meta, paramDef, async (ps, user) => {
ps.sinceDate, ps.sinceDate,
ps.untilDate, ps.untilDate,
) )
.innerJoin( .where("note.id IN (:...noteIds)", { noteIds: noteIds })
AntennaNotes.metadata.targetName,
"antennaNote",
"antennaNote.noteId = note.id",
)
.innerJoinAndSelect("note.user", "user") .innerJoinAndSelect("note.user", "user")
.leftJoinAndSelect("user.avatar", "avatar") .leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner") .leftJoinAndSelect("user.banner", "banner")
@ -81,7 +99,6 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renote.user", "renoteUser") .leftJoinAndSelect("renote.user", "renoteUser")
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar") .leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner")
.andWhere("antennaNote.antennaId = :antennaId", { antennaId: antenna.id })
.andWhere("note.visibility != 'home'"); .andWhere("note.visibility != 'home'");
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);

View file

@ -12,7 +12,9 @@ import type { UserProfile } from "@/models/entities/user-profile.js";
import { notificationTypes } from "@/types.js"; import { notificationTypes } from "@/types.js";
import { normalizeForSearch } from "@/misc/normalize-for-search.js"; import { normalizeForSearch } from "@/misc/normalize-for-search.js";
import { langmap } from "@/misc/langmap.js"; import { langmap } from "@/misc/langmap.js";
import { verifyLink } from "@/services/fetch-rel-me.js";
import { ApiError } from "../../error.js"; import { ApiError } from "../../error.js";
import config from "@/config/index.js";
import define from "../../define.js"; import define from "../../define.js";
export const meta = { export const meta = {
@ -58,6 +60,18 @@ export const meta = {
code: "INVALID_REGEXP", code: "INVALID_REGEXP",
id: "0d786918-10df-41cd-8f33-8dec7d9a89a5", id: "0d786918-10df-41cd-8f33-8dec7d9a89a5",
}, },
invalidFieldName: {
message: "Invalid field name.",
code: "INVALID_FIELD_NAME",
id: "8f81972e-8b53-4d30-b0d2-efb026dda673",
},
invalidFieldValue: {
message: "Invalid field value.",
code: "INVALID_FIELD_VALUE",
id: "aede7444-244b-11ee-be56-0242ac120002",
},
}, },
res: { res: {
@ -234,16 +248,29 @@ export default define(meta, paramDef, async (ps, _user, token) => {
} }
if (ps.fields) { if (ps.fields) {
for (const field of ps.fields) {
if (!field || field.name === "" || field.value === "") {
continue;
}
if (typeof field.name !== "string" || field.name === "") {
throw new ApiError(meta.errors.invalidFieldName);
}
if (typeof field.value !== "string" || field.value === "") {
throw new ApiError(meta.errors.invalidFieldValue);
}
if (field.value.startsWith("http")) {
field.verified = await verifyLink(field.value, user.username);
}
}
profileUpdates.fields = ps.fields profileUpdates.fields = ps.fields
.filter( .filter((x) => Object.keys(x).length !== 0)
(x) =>
typeof x.name === "string" &&
x.name !== "" &&
typeof x.value === "string" &&
x.value !== "",
)
.map((x) => { .map((x) => {
return { name: x.name, value: x.value }; return {
name: x.name,
value: x.value,
verified: x.verified,
};
}); });
} }

View file

@ -1,4 +1,6 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import net from "node:net";
import { promises } from "node:dns";
import type Koa from "koa"; import type Koa from "koa";
import sharp from "sharp"; import sharp from "sharp";
import type { IImage } from "@/services/drive/image-processor.js"; import type { IImage } from "@/services/drive/image-processor.js";
@ -19,6 +21,40 @@ export async function proxyMedia(ctx: Koa.Context) {
return; return;
} }
const { hostname } = new URL(url);
let resolvedIps;
try {
resolvedIps = await promises.resolve(hostname);
} catch (error) {
ctx.status = 400;
ctx.body = { message: "Invalid URL" };
return;
}
const isSSRF = resolvedIps.some((ip) => {
if (net.isIPv4(ip)) {
const parts = ip.split(".").map(Number);
return (
parts[0] === 10 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] < 32) ||
(parts[0] === 192 && parts[1] === 168) ||
parts[0] === 127 ||
parts[0] === 0
);
} else if (net.isIPv6(ip)) {
return (
ip.startsWith("::") || ip.startsWith("fc00:") || ip.startsWith("fe80:")
);
}
return false;
});
if (isSSRF) {
ctx.status = 400;
ctx.body = { message: "Access to this URL is not allowed" };
return;
}
// Create temp file // Create temp file
const [path, cleanup] = await createTemp(); const [path, cleanup] = await createTemp();

View file

@ -1,61 +1,24 @@
import type { Antenna } from "@/models/entities/antenna.js"; import type { Antenna } from "@/models/entities/antenna.js";
import type { Note } from "@/models/entities/note.js"; import type { Note } from "@/models/entities/note.js";
import { AntennaNotes, Mutings, Notes } from "@/models/index.js";
import { genId } from "@/misc/gen-id.js"; import { genId } from "@/misc/gen-id.js";
import { isUserRelated } from "@/misc/is-user-related.js"; import { redisClient } from "@/db/redis.js";
import { publishAntennaStream, publishMainStream } from "@/services/stream.js"; import { publishAntennaStream } from "@/services/stream.js";
import type { User } from "@/models/entities/user.js"; import type { User } from "@/models/entities/user.js";
export async function addNoteToAntenna( export async function addNoteToAntenna(
antenna: Antenna, antenna: Antenna,
note: Note, note: Note,
noteUser: { id: User["id"] }, _noteUser: { id: User["id"] },
) { ) {
// 通知しない設定になっているか、自分自身の投稿なら既読にする redisClient.xadd(
const read = !antenna.notify || antenna.userId === noteUser.id; `antennaTimeline:${antenna.id}`,
"MAXLEN",
AntennaNotes.insert({ "~",
id: genId(), "200",
antennaId: antenna.id, "*",
noteId: note.id, "note",
read: read, note.id,
}); );
publishAntennaStream(antenna.id, "note", note); publishAntennaStream(antenna.id, "note", note);
if (!read) {
const mutings = await Mutings.find({
where: {
muterId: antenna.userId,
},
select: ["muteeId"],
});
// Copy
const _note: Note = {
...note,
};
if (note.replyId != null) {
_note.reply = await Notes.findOneByOrFail({ id: note.replyId });
}
if (note.renoteId != null) {
_note.renote = await Notes.findOneByOrFail({ id: note.renoteId });
}
if (isUserRelated(_note, new Set<string>(mutings.map((x) => x.muteeId)))) {
return;
}
// 2秒経っても既読にならなかったら通知
setTimeout(async () => {
const unread = await AntennaNotes.findOneBy({
antennaId: antenna.id,
read: false,
});
if (unread) {
publishMainStream(antenna.userId, "unreadAntenna", antenna);
}
}, 2000);
}
} }

View file

@ -0,0 +1,33 @@
import { getHtml } from "@/misc/fetch.js";
import { JSDOM } from "jsdom";
import config from "@/config/index.js";
async function getRelMeLinks(url: string): Promise<string[]> {
try {
const html = await getHtml(url);
const dom = new JSDOM(html);
const relMeLinks = [
...dom.window.document.querySelectorAll("a[rel='me']"),
...dom.window.document.querySelectorAll("link[rel='me']"),
].map((a) => (a as HTMLAnchorElement | HTMLLinkElement).href);
return relMeLinks;
} catch {
return [];
}
}
export async function verifyLink(link: string, username: string): Promise<boolean> {
let verified = false;
if (link.startsWith("http")) {
const relMeLinks = await getRelMeLinks(link);
verified = relMeLinks.some((href) =>
new RegExp(
`^https?:\/\/${config.host.replace(
/[.*+\-?^${}()|[\]\\]/g,
"\\$&",
)}\/@${username.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&")}$`,
).test(href),
);
}
return verified;
}

View file

@ -3,7 +3,6 @@ import type { Note } from "@/models/entities/note.js";
import type { User } from "@/models/entities/user.js"; import type { User } from "@/models/entities/user.js";
import { import {
NoteUnreads, NoteUnreads,
AntennaNotes,
Users, Users,
Followings, Followings,
ChannelFollowings, ChannelFollowings,
@ -51,11 +50,11 @@ export default async function (
).map((x) => x.followeeId), ).map((x) => x.followeeId),
); );
const myAntennas = (await getAntennas()).filter((a) => a.userId === userId); // const myAntennas = (await getAntennas()).filter((a) => a.userId === userId);
const readMentions: (Note | Packed<"Note">)[] = []; const readMentions: (Note | Packed<"Note">)[] = [];
const readSpecifiedNotes: (Note | Packed<"Note">)[] = []; const readSpecifiedNotes: (Note | Packed<"Note">)[] = [];
const readChannelNotes: (Note | Packed<"Note">)[] = []; const readChannelNotes: (Note | Packed<"Note">)[] = [];
const readAntennaNotes: (Note | Packed<"Note">)[] = []; // const readAntennaNotes: (Note | Packed<"Note">)[] = [];
for (const note of notes) { for (const note of notes) {
if (note.mentions?.includes(userId)) { if (note.mentions?.includes(userId)) {
@ -68,22 +67,22 @@ export default async function (
readChannelNotes.push(note); readChannelNotes.push(note);
} }
if (note.user != null) { // if (note.user != null) {
// たぶんnullになることは無いはずだけど一応 // // たぶんnullになることは無いはずだけど一応
for (const antenna of myAntennas) { // for (const antenna of myAntennas) {
if ( // if (
await checkHitAntenna( // await checkHitAntenna(
antenna, // antenna,
note, // note,
note.user, // note.user,
undefined, // undefined,
Array.from(following), // Array.from(following),
) // )
) { // ) {
readAntennaNotes.push(note); // readAntennaNotes.push(note);
} // }
} // }
} // }
} }
if ( if (
@ -141,33 +140,33 @@ export default async function (
}); });
} }
if (readAntennaNotes.length > 0) { // if (readAntennaNotes.length > 0) {
await AntennaNotes.update( // await AntennaNotes.update(
{ // {
antennaId: In(myAntennas.map((a) => a.id)), // antennaId: In(myAntennas.map((a) => a.id)),
noteId: In(readAntennaNotes.map((n) => n.id)), // noteId: In(readAntennaNotes.map((n) => n.id)),
}, // },
{ // {
read: true, // read: true,
}, // },
); // );
// TODO: まとめてクエリしたい // // TODO: まとめてクエリしたい
for (const antenna of myAntennas) { // for (const antenna of myAntennas) {
const count = await AntennaNotes.countBy({ // const count = await AntennaNotes.countBy({
antennaId: antenna.id, // antennaId: antenna.id,
read: false, // read: false,
}); // });
if (count === 0) { // if (count === 0) {
publishMainStream(userId, "readAntenna", antenna); // publishMainStream(userId, "readAntenna", antenna);
} // }
} // }
Users.getHasUnreadAntenna(userId).then((unread) => { // Users.getHasUnreadAntenna(userId).then((unread) => {
if (!unread) { // if (!unread) {
publishMainStream(userId, "readAllAntennas"); // publishMainStream(userId, "readAllAntennas");
} // }
}); // });
} // }
} }

View file

@ -38,7 +38,11 @@ export type UserDetailed = UserLite & {
createdAt: DateString; createdAt: DateString;
description: string | null; description: string | null;
ffVisibility: "public" | "followers" | "private"; ffVisibility: "public" | "followers" | "private";
fields: { name: string; value: string }[]; fields: {
name: string;
value: string;
verified?: boolean;
}[];
followersCount: number; followersCount: number;
followingCount: number; followingCount: number;
hasPendingFollowRequestFromYou: boolean; hasPendingFollowRequestFromYou: boolean;

View file

@ -0,0 +1,7 @@
{
"extends": ["@eslint-sets/vue3", "@eslint-sets/vue3-ts"],
"plugins": ["file-progress", "prettier"],
"rules": {
"file-progress/activate": 1
}
}

View file

@ -4,11 +4,14 @@
"scripts": { "scripts": {
"watch": "pnpm vite build --watch --mode development", "watch": "pnpm vite build --watch --mode development",
"build": "pnpm vite build", "build": "pnpm vite build",
"lint": "pnpm rome check \"src/**/*.{ts,vue}\"", "lint": "pnpm rome check **/*.ts --apply && pnpm run lint:vue",
"format": "pnpm rome format * --write && pnpm prettier --write '**/*.{scss,vue}'" "lint:vue": "pnpm paralint --ext .vue --fix '**/*.vue' --cache",
"format": "pnpm rome format * --write && pnpm prettier --write '**/*.{scss,vue}' --cache --cache-strategy metadata"
}, },
"devDependencies": { "devDependencies": {
"@discordapp/twemoji": "14.1.2", "@discordapp/twemoji": "14.1.2",
"@eslint-sets/eslint-config-vue3": "^5.6.1",
"@eslint-sets/eslint-config-vue3-ts": "^3.3.0",
"@phosphor-icons/web": "^2.0.3", "@phosphor-icons/web": "^2.0.3",
"@rollup/plugin-alias": "3.1.9", "@rollup/plugin-alias": "3.1.9",
"@rollup/plugin-json": "4.1.0", "@rollup/plugin-json": "4.1.0",
@ -46,6 +49,8 @@
"date-fns": "2.30.0", "date-fns": "2.30.0",
"emojilib": "github:thatonecalculator/emojilib", "emojilib": "github:thatonecalculator/emojilib",
"escape-regexp": "0.0.1", "escape-regexp": "0.0.1",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-file-progress": "^1.3.0",
"eventemitter3": "5.0.1", "eventemitter3": "5.0.1",
"fast-blurhash": "^1.1.2", "fast-blurhash": "^1.1.2",
"focus-trap": "^7.5.2", "focus-trap": "^7.5.2",
@ -57,6 +62,7 @@
"katex": "0.16.8", "katex": "0.16.8",
"matter-js": "0.18.0", "matter-js": "0.18.0",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
"paralint": "^1.2.1",
"photoswipe": "5.3.8", "photoswipe": "5.3.8",
"prettier": "3.0.0", "prettier": "3.0.0",
"prettier-plugin-vue": "1.1.6", "prettier-plugin-vue": "1.1.6",

View file

@ -80,11 +80,11 @@ const emit = defineEmits<{
(ev: "resolved", reportId: string): void; (ev: "resolved", reportId: string): void;
}>(); }>();
let forward = $ref(props.report.forwarded); const forward = $ref(props.report.forwarded);
function resolve() { function resolve() {
os.apiWithDialog("admin/resolve-abuse-user-report", { os.apiWithDialog("admin/resolve-abuse-user-report", {
forward: forward, forward,
reportId: props.report.id, reportId: props.report.id,
}).then(() => { }).then(() => {
emit("resolved", props.report.id); emit("resolved", props.report.id);

View file

@ -41,7 +41,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import * as Misskey from "calckey-js"; import type * as Misskey from "calckey-js";
import XWindow from "@/components/MkWindow.vue"; import XWindow from "@/components/MkWindow.vue";
import MkTextarea from "@/components/form/textarea.vue"; import MkTextarea from "@/components/form/textarea.vue";
import MkButton from "@/components/MkButton.vue"; import MkButton from "@/components/MkButton.vue";

View file

@ -109,12 +109,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import {
ref,
computed, computed,
onMounted,
onBeforeUnmount,
shallowRef,
nextTick, nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
} from "vue"; } from "vue";
import tinycolor from "tinycolor2"; import tinycolor from "tinycolor2";
import { globalEvents } from "@/events.js"; import { globalEvents } from "@/events.js";
@ -173,21 +173,21 @@ const texts = computed(() => {
return angles; return angles;
}); });
let enabled = true; let enabled = true,
let majorGraduationColor = $ref<string>(); majorGraduationColor = $ref<string>(),
//let minorGraduationColor = $ref<string>(); // let minorGraduationColor = $ref<string>();
let sHandColor = $ref<string>(); sHandColor = $ref<string>(),
let mHandColor = $ref<string>(); mHandColor = $ref<string>(),
let hHandColor = $ref<string>(); hHandColor = $ref<string>(),
let nowColor = $ref<string>(); nowColor = $ref<string>(),
let h = $ref<number>(0); h = $ref<number>(0),
let m = $ref<number>(0); m = $ref<number>(0),
let s = $ref<number>(0); s = $ref<number>(0),
let hAngle = $ref<number>(0); hAngle = $ref<number>(0),
let mAngle = $ref<number>(0); mAngle = $ref<number>(0),
let sAngle = $ref<number>(0); sAngle = $ref<number>(0),
let disableSAnimate = $ref(false); disableSAnimate = $ref(false),
let sOneRound = false; sOneRound = false;
function tick() { function tick() {
const now = new Date(); const now = new Date();
@ -230,7 +230,7 @@ function calcColors() {
majorGraduationColor = dark majorGraduationColor = dark
? "rgba(255, 255, 255, 0.3)" ? "rgba(255, 255, 255, 0.3)"
: "rgba(0, 0, 0, 0.3)"; : "rgba(0, 0, 0, 0.3)";
//minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; // minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
sHandColor = dark ? "rgba(255, 255, 255, 0.5)" : "rgba(0, 0, 0, 0.3)"; sHandColor = dark ? "rgba(255, 255, 255, 0.5)" : "rgba(0, 0, 0, 0.3)";
mHandColor = tinycolor( mHandColor = tinycolor(
computedStyle.getPropertyValue("--fg"), computedStyle.getPropertyValue("--fg"),

View file

@ -85,11 +85,11 @@
<script lang="ts"> <script lang="ts">
import { import {
markRaw, markRaw,
ref,
onUpdated,
onMounted,
onBeforeUnmount,
nextTick, nextTick,
onBeforeUnmount,
onMounted,
onUpdated,
ref,
watch, watch,
} from "vue"; } from "vue";
import contains from "@/scripts/contains"; import contains from "@/scripts/contains";
@ -99,17 +99,17 @@ import { acct } from "@/filters/user";
import * as os from "@/os"; import * as os from "@/os";
import { MFM_TAGS } from "@/scripts/mfm-tags"; import { MFM_TAGS } from "@/scripts/mfm-tags";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
import { emojilist, addSkinTone } from "@/scripts/emojilist"; import { addSkinTone, emojilist } from "@/scripts/emojilist";
import { instance } from "@/instance"; import { instance } from "@/instance";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
type EmojiDef = { interface EmojiDef {
emoji: string; emoji: string;
name: string; name: string;
aliasOf?: string; aliasOf?: string;
url?: string; url?: string;
isCustomEmoji?: boolean; isCustomEmoji?: boolean;
}; }
const lib = emojilist.filter((x) => x.category !== "flags"); const lib = emojilist.filter((x) => x.category !== "flags");
@ -140,7 +140,7 @@ for (const x of lib) {
emjdb.sort((a, b) => a.name.length - b.name.length); emjdb.sort((a, b) => a.name.length - b.name.length);
//#region Construct Emoji DB // #region Construct Emoji DB
const customEmojis = instance.emojis; const customEmojis = instance.emojis;
const emojiDefinitions: EmojiDef[] = []; const emojiDefinitions: EmojiDef[] = [];
@ -168,7 +168,7 @@ for (const x of customEmojis) {
emojiDefinitions.sort((a, b) => a.name.length - b.name.length); emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
const emojiDb = markRaw(emojiDefinitions.concat(emjdb)); const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
//#endregion // #endregion
export default { export default {
emojiDb, emojiDb,
@ -436,10 +436,7 @@ onMounted(() => {
setPosition(); setPosition();
props.textarea.addEventListener("keydown", onKeydown); props.textarea.addEventListener("keydown", onKeydown);
document.body.addEventListener("mousedown", onMousedown);
for (const el of Array.from(document.querySelectorAll("body *"))) {
el.addEventListener("mousedown", onMousedown);
}
nextTick(() => { nextTick(() => {
exec(); exec();
@ -457,10 +454,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
props.textarea.removeEventListener("keydown", onKeydown); props.textarea.removeEventListener("keydown", onKeydown);
document.body.removeEventListener("mousedown", onMousedown);
for (const el of Array.from(document.querySelectorAll("body *"))) {
el.removeEventListener("mousedown", onMousedown);
}
}); });
</script> </script>

View file

@ -49,8 +49,8 @@ const emit = defineEmits<{
(ev: "click", payload: MouseEvent): void; (ev: "click", payload: MouseEvent): void;
}>(); }>();
let el = $ref<HTMLElement | null>(null); const el = $ref<HTMLElement | null>(null);
let ripples = $ref<HTMLElement | null>(null); const ripples = $ref<HTMLElement | null>(null);
onMounted(() => { onMounted(() => {
if (props.autofocus) { if (props.autofocus) {

View file

@ -6,11 +6,11 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
type Captcha = { interface Captcha {
render( render(
container: string | Node, container: string | Node,
options: { options: {
@ -31,7 +31,7 @@ type Captcha = {
execute(id: string): void; execute(id: string): void;
reset(id?: string): void; reset(id?: string): void;
getResponse(id: string): string; getResponse(id: string): string;
}; }
type CaptchaProvider = "hcaptcha" | "recaptcha"; type CaptchaProvider = "hcaptcha" | "recaptcha";
@ -105,7 +105,7 @@ function requestRender() {
captcha.value.render(captchaEl.value, { captcha.value.render(captchaEl.value, {
sitekey: props.sitekey, sitekey: props.sitekey,
theme: defaultStore.state.darkMode ? "dark" : "light", theme: defaultStore.state.darkMode ? "dark" : "light",
callback: callback, callback,
"expired-callback": callback, "expired-callback": callback,
"error-callback": callback, "error-callback": callback,
}); });

View file

@ -24,7 +24,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import MkChannelPreview from "@/components/MkChannelPreview.vue"; import MkChannelPreview from "@/components/MkChannelPreview.vue";
import MkPagination, { Paging } from "@/components/MkPagination.vue"; import type { Paging } from "@/components/MkPagination.vue";
import MkPagination from "@/components/MkPagination.vue";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
const props = withDefaults( const props = withDefaults(

View file

@ -8,30 +8,31 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, watch, PropType, onUnmounted } from "vue"; import type { PropType } from "vue";
import { onMounted, onUnmounted, ref, watch } from "vue";
import { import {
Chart,
ArcElement, ArcElement,
LineElement,
BarElement,
PointElement,
BarController, BarController,
LineController, BarElement,
CategoryScale, CategoryScale,
LinearScale, Chart,
TimeScale, Filler,
Legend, Legend,
LineController,
LineElement,
LinearScale,
PointElement,
SubTitle,
TimeScale,
Title, Title,
Tooltip, Tooltip,
SubTitle,
Filler,
} from "chart.js"; } from "chart.js";
import "chartjs-adapter-date-fns"; import "chartjs-adapter-date-fns";
import { enUS } from "date-fns/locale"; import { enUS } from "date-fns/locale";
import zoomPlugin from "chartjs-plugin-zoom"; import zoomPlugin from "chartjs-plugin-zoom";
// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114242002 // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114242002
// We can't use gradient because Vite throws a error. // We can't use gradient because Vite throws a error.
//import gradient from 'chartjs-plugin-gradient'; // import gradient from 'chartjs-plugin-gradient';
import * as os from "@/os"; import * as os from "@/os";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
import { useChartTooltip } from "@/scripts/use-chart-tooltip"; import { useChartTooltip } from "@/scripts/use-chart-tooltip";
@ -92,7 +93,7 @@ Chart.register(
SubTitle, SubTitle,
Filler, Filler,
zoomPlugin, zoomPlugin,
//gradient, // gradient,
); );
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
@ -127,8 +128,8 @@ const getColor = (i) => {
}; };
const now = new Date(); const now = new Date();
let chartInstance: Chart = null; let chartInstance: Chart = null,
let chartData: { chartData: {
series: { series: {
name: string; name: string;
type: "line" | "area"; type: "line" | "area";
@ -140,7 +141,7 @@ let chartData: {
y: number; y: number;
}[]; }[];
}[]; }[];
} = null; } = null;
const chartEl = ref<HTMLCanvasElement>(null); const chartEl = ref<HTMLCanvasElement>(null);
const fetching = ref(true); const fetching = ref(true);
@ -210,7 +211,7 @@ const render = () => {
? x.color ? x.color
: getColor(i) : getColor(i)
: alpha(x.color ? x.color : getColor(i), 0.1), : alpha(x.color ? x.color : getColor(i), 0.1),
/*gradient: props.bar ? undefined : { /* gradient: props.bar ? undefined : {
backgroundColor: { backgroundColor: {
axis: 'y', axis: 'y',
colors: { colors: {
@ -218,7 +219,7 @@ const render = () => {
[maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2), [maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2),
}, },
}, },
},*/ }, */
barPercentage: 0.9, barPercentage: 0.9,
categoryPercentage: 0.9, categoryPercentage: 0.9,
fill: x.type === "area", fill: x.type === "area",
@ -271,7 +272,7 @@ const render = () => {
}, },
ticks: { ticks: {
display: props.detailed, display: props.detailed,
//mirror: true, // mirror: true,
}, },
}, },
}, },
@ -331,7 +332,7 @@ const render = () => {
}, },
} }
: undefined, : undefined,
//gradient, // gradient,
}, },
}, },
plugins: [ plugins: [

View file

@ -26,7 +26,7 @@
: message.user : message.user
" "
:show-indicator="true" :show-indicator="true"
disableLink disable-link
/> />
<header v-if="message.groupId"> <header v-if="message.groupId">
<span class="name">{{ message.group.name }}</span> <span class="name">{{ message.group.name }}</span>

View file

@ -12,9 +12,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onBeforeUnmount } from "vue"; import { onBeforeUnmount, onMounted } from "vue";
import MkMenu from "./MkMenu.vue"; import MkMenu from "./MkMenu.vue";
import { MenuItem } from "./types/menu.vue"; import type { MenuItem } from "./types/menu.vue";
import contains from "@/scripts/contains"; import contains from "@/scripts/contains";
import * as os from "@/os"; import * as os from "@/os";
@ -27,13 +27,13 @@ const emit = defineEmits<{
(ev: "closed"): void; (ev: "closed"): void;
}>(); }>();
let rootEl = $ref<HTMLDivElement>(); const rootEl = $ref<HTMLDivElement>();
let zIndex = $ref<number>(os.claimZIndex("high")); const zIndex = $ref<number>(os.claimZIndex("high"));
onMounted(() => { onMounted(() => {
let left = props.ev.pageX + 1; // + 1 let left = props.ev.pageX + 1, // + 1
let top = props.ev.pageY + 1; // + 1 top = props.ev.pageY + 1; // + 1
const width = rootEl.offsetWidth; const width = rootEl.offsetWidth;
const height = rootEl.offsetHeight; const height = rootEl.offsetHeight;
@ -57,15 +57,11 @@ onMounted(() => {
rootEl.style.top = `${top}px`; rootEl.style.top = `${top}px`;
rootEl.style.left = `${left}px`; rootEl.style.left = `${left}px`;
for (const el of Array.from(document.querySelectorAll("body *"))) { document.body.addEventListener("mousedown", onMousedown);
el.addEventListener("mousedown", onMousedown);
}
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
for (const el of Array.from(document.querySelectorAll("body *"))) { document.body.removeEventListener("mousedown", onMousedown);
el.removeEventListener("mousedown", onMousedown);
}
}); });
function onMousedown(evt: Event) { function onMousedown(evt: Event) {

View file

@ -37,7 +37,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onMounted } from "vue"; import { nextTick, onMounted } from "vue";
import * as misskey from "calckey-js"; import type * as misskey from "calckey-js";
import Cropper from "cropperjs"; import Cropper from "cropperjs";
import tinycolor from "tinycolor2"; import tinycolor from "tinycolor2";
import XModalWindow from "@/components/MkModalWindow.vue"; import XModalWindow from "@/components/MkModalWindow.vue";
@ -62,10 +62,10 @@ const props = defineProps<{
const imgUrl = `${url}/proxy/image.webp?${query({ const imgUrl = `${url}/proxy/image.webp?${query({
url: props.file.url, url: props.file.url,
})}`; })}`;
let dialogEl = $ref<InstanceType<typeof XModalWindow>>(); const dialogEl = $ref<InstanceType<typeof XModalWindow>>();
let imgEl = $ref<HTMLImageElement>(); const imgEl = $ref<HTMLImageElement>();
let cropper: Cropper | null = null; let cropper: Cropper | null = null,
let loading = $ref(true); loading = $ref(true);
const ok = async () => { const ok = async () => {
const promise = new Promise<misskey.entities.DriveFile>(async (res) => { const promise = new Promise<misskey.entities.DriveFile>(async (res) => {

View file

@ -15,7 +15,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { length } from "stringz"; import { length } from "stringz";
import * as misskey from "calckey-js"; import type * as misskey from "calckey-js";
import { concat } from "@/scripts/array"; import { concat } from "@/scripts/array";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, h, PropType, TransitionGroup } from "vue"; import type { PropType } from "vue";
import { TransitionGroup, defineComponent, h } from "vue";
import MkAd from "@/components/global/MkAd.vue"; import MkAd from "@/components/global/MkAd.vue";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
@ -51,7 +52,7 @@ export default defineComponent({
if (!slots || !slots.default) return; if (!slots || !slots.default) return;
const el = slots.default({ const el = slots.default({
item: item, item,
})[0]; })[0];
if (el.key == null && item.id) el.key = item.id; if (el.key == null && item.id) el.key = item.id;

View file

@ -57,17 +57,17 @@
<Mfm :text="text" /> <Mfm :text="text" />
</div> </div>
<MkInput <MkInput
ref="inputEl"
v-if="input && input.type !== 'paragraph'" v-if="input && input.type !== 'paragraph'"
ref="inputEl"
v-model="inputValue" v-model="inputValue"
autofocus autofocus
:autocomplete="input.autocomplete" :autocomplete="input.autocomplete"
:type="input.type == 'search' ? 'search' : input.type || 'text'" :type="input.type == 'search' ? 'search' : input.type || 'text'"
:placeholder="input.placeholder || undefined" :placeholder="input.placeholder || undefined"
@keydown="onInputKeydown"
:style="{ :style="{
width: input.type === 'search' ? '300px' : null, width: input.type === 'search' ? '300px' : null,
}" }"
@keydown="onInputKeydown"
> >
<template v-if="input.type === 'password'" #prefix <template v-if="input.type === 'password'" #prefix
><i class="ph-password ph-bold ph-lg"></i ><i class="ph-password ph-bold ph-lg"></i
@ -100,9 +100,9 @@
</template> </template>
<template v-if="input.type === 'search'" #suffix> <template v-if="input.type === 'search'" #suffix>
<button <button
v-tooltip.noDelay="i18n.ts.filter"
class="_buttonIcon" class="_buttonIcon"
@click.stop="openSearchFilters" @click.stop="openSearchFilters"
v-tooltip.noDelay="i18n.ts.filter"
> >
<i class="ph-funnel ph-bold"></i> <i class="ph-funnel ph-bold"></i>
</button> </button>
@ -200,6 +200,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef } from "vue"; import { onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import * as Acct from "calckey-js/built/acct";
import MkModal from "@/components/MkModal.vue"; import MkModal from "@/components/MkModal.vue";
import MkButton from "@/components/MkButton.vue"; import MkButton from "@/components/MkButton.vue";
import MkInput from "@/components/form/input.vue"; import MkInput from "@/components/form/input.vue";
@ -207,18 +208,17 @@ import MkTextarea from "@/components/form/textarea.vue";
import MkSelect from "@/components/form/select.vue"; import MkSelect from "@/components/form/select.vue";
import * as os from "@/os"; import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import * as Acct from "calckey-js/built/acct";
type Input = { interface Input {
type: HTMLInputElement["type"]; type: HTMLInputElement["type"];
placeholder?: string | null; placeholder?: string | null;
autocomplete?: string; autocomplete?: string;
default: string | number | null; default: string | number | null;
minLength?: number; minLength?: number;
maxLength?: number; maxLength?: number;
}; }
type Select = { interface Select {
items: { items: {
value: string; value: string;
text: string; text: string;
@ -231,7 +231,7 @@ type Select = {
}[]; }[];
}[]; }[];
default: string | null; default: string | null;
}; }
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{

View file

@ -49,8 +49,8 @@
<button <button
class="_button" class="_button"
:class="$style.close" :class="$style.close"
@click="close"
:aria-label="i18n.t('close')" :aria-label="i18n.t('close')"
@click="close"
> >
<i class="ph-x ph-bold ph-lg"></i> <i class="ph-x ph-bold ph-lg"></i>
</button> </button>
@ -59,14 +59,14 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, nextTick } from "vue"; import { nextTick, ref } from "vue";
import MkButton from "@/components/MkButton.vue"; import MkButton from "@/components/MkButton.vue";
import { host } from "@/config"; import { host } from "@/config";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import * as os from "@/os"; import * as os from "@/os";
import { instance } from "@/instance"; import { instance } from "@/instance";
let show = ref(false); const show = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
(ev: "closed"): void; (ev: "closed"): void;

View file

@ -195,7 +195,7 @@ const props = withDefaults(
); );
const emit = defineEmits<{ const emit = defineEmits<{
(ev: "chosen", v: string): void; (ev: "chosen", v: string, ev: MouseEvent): void;
}>(); }>();
const search = ref<HTMLInputElement>(); const search = ref<HTMLInputElement>();
@ -436,7 +436,7 @@ function chosen(emoji: any, ev?: MouseEvent) {
} }
const key = getKey(emoji); const key = getKey(emoji);
emit("chosen", key); emit("chosen", key, ev);
// 使 // 使
if (!pinned.value.includes(key)) { if (!pinned.value.includes(key)) {

View file

@ -58,29 +58,15 @@ const emit = defineEmits<{
const modal = ref<InstanceType<typeof MkModal>>(); const modal = ref<InstanceType<typeof MkModal>>();
const picker = ref<InstanceType<typeof MkEmojiPicker>>(); const picker = ref<InstanceType<typeof MkEmojiPicker>>();
const isShiftKeyPressed = ref(false);
const keydownHandler = (e) => {
if (e.key === "Shift") {
isShiftKeyPressed.value = true;
}
};
const keyupHandler = (e) => {
if (e.key === "Shift") {
isShiftKeyPressed.value = false;
}
};
function checkForShift(ev?: MouseEvent) { function checkForShift(ev?: MouseEvent) {
if (!isShiftKeyPressed.value) { if (ev?.shiftKey) return;
modal.value?.close(ev); modal.value?.close(ev);
}
} }
function chosen(emoji: any) { function chosen(emoji: any, ev: MouseEvent) {
emit("done", emoji); emit("done", emoji);
checkForShift(); checkForShift(ev);
} }
function opening() { function opening() {
@ -91,16 +77,6 @@ function opening() {
} }
picker.value?.focus(); picker.value?.focus();
} }
onMounted(() => {
window.addEventListener("keydown", keydownHandler);
window.addEventListener("keyup", keyupHandler);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", keydownHandler);
window.removeEventListener("keyup", keyupHandler);
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -54,14 +54,19 @@
controls controls
@contextmenu.stop @contextmenu.stop
> >
<source :src="media.url" :type="media.type" /> <source :src="media.url" :type="mediaType" />
</video> </video>
</VuePlyr> </VuePlyr>
</template> </template>
<div class="buttons"> <div class="buttons">
<button <button
v-if="media.comment" v-if="media.comment"
v-tooltip="i18n.ts.alt" v-tooltip.noLabel="`${i18n.ts.alt}: ${
media.comment.length > 200 ?
media.comment.trim().slice(0, 200) + '...'
: media.comment.trim()
}`"
:aria-label="i18n.ts.alt"
class="_button" class="_button"
@click.stop="captionPopup" @click.stop="captionPopup"
> >
@ -80,7 +85,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch, ref } from "vue"; import { watch, ref, computed } from "vue";
import VuePlyr from "vue-plyr"; import VuePlyr from "vue-plyr";
import "vue-plyr/dist/vue-plyr.css"; import "vue-plyr/dist/vue-plyr.css";
import type * as misskey from "calckey-js"; import type * as misskey from "calckey-js";
@ -107,6 +112,12 @@ const url =
? getStaticImageUrl(props.media.thumbnailUrl) ? getStaticImageUrl(props.media.thumbnailUrl)
: props.media.thumbnailUrl; : props.media.thumbnailUrl;
const mediaType = computed(() => {
return props.media.type === "video/quicktime"
? "video/mp4"
: props.media.type;
});
function captionPopup() { function captionPopup() {
os.alert({ os.alert({
type: "info", type: "info",

View file

@ -148,7 +148,7 @@
{{ appearNote.channel.name }}</MkA {{ appearNote.channel.name }}</MkA
> >
</div> </div>
<footer ref="footerEl" class="footer" @click.stop tabindex="-1"> <footer ref="footerEl" class="footer" tabindex="-1">
<XReactionsViewer <XReactionsViewer
v-if="enableEmojiReactions" v-if="enableEmojiReactions"
ref="reactionsViewer" ref="reactionsViewer"
@ -157,7 +157,7 @@
<button <button
v-tooltip.noDelay.bottom="i18n.ts.reply" v-tooltip.noDelay.bottom="i18n.ts.reply"
class="button _button" class="button _button"
@click="reply()" @click.stop="reply()"
> >
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<template <template
@ -202,7 +202,7 @@
ref="reactButton" ref="reactButton"
v-tooltip.noDelay.bottom="i18n.ts.reaction" v-tooltip.noDelay.bottom="i18n.ts.reaction"
class="button _button" class="button _button"
@click="react()" @click.stop="react()"
> >
<i class="ph-smiley ph-bold ph-lg"></i> <i class="ph-smiley ph-bold ph-lg"></i>
</button> </button>
@ -213,7 +213,7 @@
" "
ref="reactButton" ref="reactButton"
class="button _button reacted" class="button _button reacted"
@click="undoReact(appearNote)" @click.stop="undoReact(appearNote)"
v-tooltip.noDelay.bottom="i18n.ts.removeReaction" v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
> >
<i class="ph-minus ph-bold ph-lg"></i> <i class="ph-minus ph-bold ph-lg"></i>
@ -223,7 +223,7 @@
ref="menuButton" ref="menuButton"
v-tooltip.noDelay.bottom="i18n.ts.more" v-tooltip.noDelay.bottom="i18n.ts.more"
class="button _button" class="button _button"
@click="menu()" @click.stop="menu()"
> >
<i class="ph-dots-three-outline ph-bold ph-lg"></i> <i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button> </button>
@ -862,7 +862,6 @@ defineExpose({
z-index: 2; z-index: 2;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
pointer-events: none; // Allow clicking anything w/out pointer-events: all; to open post
margin-top: 0.4em; margin-top: 0.4em;
> :deep(.button) { > :deep(.button) {
position: relative; position: relative;
@ -876,7 +875,6 @@ defineExpose({
max-width: 3.5em; max-width: 3.5em;
width: max-content; width: max-content;
min-width: max-content; min-width: max-content;
pointer-events: all;
height: auto; height: auto;
transition: opacity 0.2s; transition: opacity 0.2s;
&::before { &::before {

View file

@ -33,12 +33,7 @@
detailedView detailedView
></MkNote> ></MkNote>
<MkTab <MkTab v-model="tab" :style="'underline'" @update:modelValue="loadTab">
v-model="tab"
style="white-space: nowrap"
:style="'underline'"
@update:modelValue="loadTab"
>
<option value="replies"> <option value="replies">
<!-- <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> --> <!-- <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> -->
<span v-if="note.repliesCount > 0" class="count">{{ <span v-if="note.repliesCount > 0" class="count">{{

View file

@ -56,7 +56,7 @@
</div> </div>
</div> </div>
</div> </div>
<footer ref="footerEl" class="footer" @click.stop tabindex="-1"> <footer ref="footerEl" class="footer" tabindex="-1">
<XReactionsViewer <XReactionsViewer
v-if="enableEmojiReactions" v-if="enableEmojiReactions"
ref="reactionsViewer" ref="reactionsViewer"
@ -65,7 +65,7 @@
<button <button
v-tooltip.noDelay.bottom="i18n.ts.reply" v-tooltip.noDelay.bottom="i18n.ts.reply"
class="button _button" class="button _button"
@click="reply()" @click.stop="reply()"
> >
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<template v-if="appearNote.repliesCount > 0"> <template v-if="appearNote.repliesCount > 0">
@ -107,7 +107,7 @@
ref="reactButton" ref="reactButton"
v-tooltip.noDelay.bottom="i18n.ts.reaction" v-tooltip.noDelay.bottom="i18n.ts.reaction"
class="button _button" class="button _button"
@click="react()" @click.stop="react()"
> >
<i class="ph-smiley ph-bold ph-lg"></i> <i class="ph-smiley ph-bold ph-lg"></i>
</button> </button>
@ -118,7 +118,7 @@
" "
ref="reactButton" ref="reactButton"
class="button _button reacted" class="button _button reacted"
@click="undoReact(appearNote)" @click.stop="undoReact(appearNote)"
v-tooltip.noDelay.bottom="i18n.ts.removeReaction" v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
> >
<i class="ph-minus ph-bold ph-lg"></i> <i class="ph-minus ph-bold ph-lg"></i>
@ -128,7 +128,7 @@
ref="menuButton" ref="menuButton"
v-tooltip.noDelay.bottom="i18n.ts.more" v-tooltip.noDelay.bottom="i18n.ts.more"
class="button _button" class="button _button"
@click="menu()" @click.stop="menu()"
> >
<i class="ph-dots-three-outline ph-bold ph-lg"></i> <i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button> </button>
@ -470,7 +470,6 @@ function noteClick(e) {
z-index: 2; z-index: 2;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
pointer-events: none; // Allow clicking anything w/out pointer-events: all; to open post
> :deep(.button) { > :deep(.button) {
position: relative; position: relative;
@ -484,7 +483,6 @@ function noteClick(e) {
max-width: 3.5em; max-width: 3.5em;
width: max-content; width: max-content;
min-width: max-content; min-width: max-content;
pointer-events: all;
height: auto; height: auto;
transition: opacity 0.2s; transition: opacity 0.2s;
&::before { &::before {

View file

@ -3,7 +3,7 @@
v-if="canRenote && $store.state.seperateRenoteQuote" v-if="canRenote && $store.state.seperateRenoteQuote"
v-tooltip.noDelay.bottom="i18n.ts.quote" v-tooltip.noDelay.bottom="i18n.ts.quote"
class="eddddedb _button" class="eddddedb _button"
@click="quote()" @click.stop="quote()"
> >
<i class="ph-quotes ph-bold ph-lg"></i> <i class="ph-quotes ph-bold ph-lg"></i>
</button> </button>

View file

@ -9,7 +9,7 @@
canToggle, canToggle,
newlyAdded: !isInitial, newlyAdded: !isInitial,
}" }"
@click="toggleReaction()" @click.stop="toggleReaction()"
> >
<XReactionIcon <XReactionIcon
class="icon" class="icon"
@ -100,13 +100,20 @@ useTooltip(
<style lang="scss" scoped> <style lang="scss" scoped>
.hkzvhatu { .hkzvhatu {
position: relative;
display: inline-block; display: inline-block;
height: 32px; height: 32px;
margin: 2px; margin-block: 2px;
padding: 0 6px; padding: 0 8px;
border-radius: 4px;
pointer-events: all; pointer-events: all;
min-width: max-content; min-width: max-content;
&::before {
content: "";
position: absolute;
inset: 0 2px;
border-radius: 4px;
z-index: -1;
}
&.newlyAdded { &.newlyAdded {
animation: scaleInSmall 0.3s cubic-bezier(0, 0, 0, 1.2); animation: scaleInSmall 0.3s cubic-bezier(0, 0, 0, 1.2);
:deep(.mk-emoji) { :deep(.mk-emoji) {
@ -126,9 +133,10 @@ useTooltip(
} }
} }
&.canToggle { &.canToggle {
&::before {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
}
&:hover { &:hover:not(.reacted)::before {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
} }
} }
@ -139,9 +147,7 @@ useTooltip(
&.reacted { &.reacted {
order: -1; order: -1;
background: var(--accent); &::before {
&:hover {
background: var(--accent); background: var(--accent);
} }

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