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 └─────────────────────────────────────
# Maximum length of a post (default 3000, max 250000000)
# Maximum length of a post (default 3000, max 100000)
#maxNoteLength: 3000
# 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/LICENSE
#docker dev config
# docker dev config
/dev/docker-compose.yml
# misskey
@ -46,6 +46,7 @@ files
ormconfig.json
packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3
packages/backend/assets/LICENSE
!packages/backend/src/db

View file

@ -6,23 +6,16 @@
## Planned
- 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/)
- Use [Magic RegExP](https://regexp.dev/) for RegEx 🦄
- Function
- User "choices" (recommended users) and featured hashtags like Mastodon and Soapbox
- Join Reason system like Mastodon/Pleroma
- Option to publicize server blocks
- More antenna options
- Groups
- Form
- Lookup/details for post/file/server
- [Rat mode?](https://stop.voring.me/notes/933fx97bmd)
## Work in progress
- Link verification
- Better Messaging UI
- Better API Documentation
- Remote follow button
@ -30,6 +23,7 @@
- Timeline filters
- Events
- Fully revamp non-logged-in screen
- Optionally use [ScyllaDB](https://www.scylladb.com/open-source-nosql-database/) for storing notes
## Implemented
@ -122,6 +116,9 @@
- Let moderators see moderation nodes
- Non-mangled unicode emojis
- 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)

View file

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

View file

@ -1268,7 +1268,7 @@ _profile:
youCanIncludeHashtags: "হ্যাশট্যাগ অন্তর্ভুক্ত করা যেতে পারে।"
metadata: "অতিরিক্ত তথ্য"
metadataEdit: "অতিরিক্ত তথ্য সম্পাদনা করুন"
metadataDescription: "আপনি আপনার প্রোফাইলে একটি টেবিল হিসাবে চারটি অতিরিক্ত তথ্য দেখাতে পারেন।"
metadataDescription: "আপনি আপনার প্রোফাইলে একটি টেবিল হিসাবে চারটি অতিরিক্ত তথ্য দেখাতে পারেন।. আপনি আপনার প্রোফাইলে লিঙ্কটি যাচাই করতে {rel} এর সাথে একটি {a} ট্যাগ বা {l} ট্যাগ যোগ করতে পারেন!"
metadataLabel: "লেবেল"
metadataContent: "বিষয়বস্তু"
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
a altres usuaris.
name: Nom
metadataDescription: Fent servir això, podràs mostrar camps d'informació addicionals
al vostre perfil.
metadataDescription: "Fent servir això, podràs mostrar camps d'informació addicionals
al vostre perfil. Podeu afegir una etiqueta {a} o una etiqueta {l} amb {rel} per
verificar l'enllaç al vostre perfil!"
_exportOrImport:
followingList: "Usuaris que segueixes"
muteList: "Silencia"
@ -1609,6 +1610,7 @@ _aboutMisskey:
pleaseDonateToHost: Penseu també en fer una donació a la vostre instància, {host},
per ajudar-lo a suportar els costos de funcionament.
donateHost: Fes una donació a {host}
sponsors: Patrocinadors de Calckey
unknown: Desconegut
pageLikesCount: Nombre de pàgines amb M'agrada
youAreRunningUpToDateClient: Estás fent servir la versió del client més nova.
@ -2160,3 +2162,4 @@ remindMeLater: Potser després
removeMember: Elimina el membre
removeQuote: Elimina la cita
removeRecipient: Elimina el destinatari
verifiedLink: Enllaç verificat

View file

@ -1551,7 +1551,7 @@ _profile:
metadata: "Zusätzliche Informationen"
metadataEdit: "Zusätzliche Informationen bearbeiten"
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"
metadataContent: "Inhalt"
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"
addAccount: "Add account"
loginFailed: "Failed to sign in"
showOnRemote: "View on remote server"
showOnRemote: "Open original page"
general: "General"
accountMoved: "User has moved to a new account:"
wallpaper: "Wallpaper"
@ -1124,6 +1124,7 @@ remindMeLater: "Maybe later"
removeQuote: "Remove quote"
removeRecipient: "Remove recipient"
removeMember: "Remove member"
verifiedLink: "Verified link"
_sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing
@ -1676,8 +1677,10 @@ _profile:
youCanIncludeHashtags: "You can also include hashtags in your bio."
metadata: "Additional Information"
metadataEdit: "Edit additional Information"
metadataDescription: "Using these, you can display additional information fields
in your profile."
metadataDescription:
"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"
metadataContent: "Content"
changeAvatar: "Change avatar"

View file

@ -642,7 +642,7 @@ wordMute: "Silenciar palabras"
regexpError: "Error de la expresión regular"
regexpErrorDescription: "Ocurrió un error en la expresión regular en la linea {line}
de las palabras muteadas {tab}"
instanceMute: "Instancias silenciadas"
instanceMute: "Servidores silenciados"
userSaysSomething: "{name} dijo algo"
makeActive: "Activar"
display: "Apariencia"
@ -671,14 +671,14 @@ sample: "Muestra"
abuseReports: "Reportes"
reportAbuse: "Reportar"
reportAbuseOf: "Reportar a {name}"
fillAbuseReportDescription: "Ingrese los detalles del reporte. Si hay una nota en
particular, ingrese la URL de esta."
fillAbuseReportDescription: "Ingrese los detalles del reporte. Si hay una publicación
en particular, ingrese la URL de esta."
abuseReported: "Se ha enviado el reporte. Muchas gracias."
reporter: "Reportador"
reporteeOrigin: "Reportar a"
reporterOrigin: "Origen del reporte"
forwardReport: "Transferir un informe a una instancia remota"
forwardReportIsAnonymous: "No puede ver su información de la instancia remota y aparecerá
forwardReport: "Transferir reporte a un servidor remoto"
forwardReportIsAnonymous: "No puede ver su información del servidor remoto y aparecerá
como una cuenta anónima del sistema"
send: "Enviar"
abuseMarkAsResolved: "Marcar reporte como resuelto"
@ -686,7 +686,7 @@ openInNewTab: "Abrir en una Nueva Pestaña"
openInSideView: "Abrir en una vista al costado"
defaultNavigationBehaviour: "Navegación por defecto"
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}"
random: "Aleatorio"
system: "Sistema"
@ -697,14 +697,14 @@ createNew: "Crear"
optional: "Opcional"
createNewClip: "Crear clip nuevo"
unclip: "Quitar clip"
confirmToUnclipAlreadyClippedNote: "Esta nota ya está incluida en el clip \"{name}\"\
. ¿Quiere quitar la nota del clip?"
confirmToUnclipAlreadyClippedNote: "Esta publicación ya está incluida en el clip \"\
{name}\". ¿Quiere quitar la nota del clip?"
public: "Público"
i18nInfo: "Calckey está siendo traducido a varios idiomas gracias a voluntarios. Se
puede colaborar traduciendo en {link}"
manageAccessTokens: "Administrar tokens de acceso"
accountInfo: "Información de la Cuenta"
notesCount: "Cantidad de notas"
notesCount: "Cantidad de publicaciones"
repliesCount: "Cantidad de respuestas hechas"
renotesCount: "Cantidad de renotas hechas"
repliedCount: "Cantidad de respuestas recibidas"
@ -720,7 +720,7 @@ no: "No"
driveFilesCount: "Cantidad de archivos en el drive"
driveUsage: "Uso del drive"
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."
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
@ -734,7 +734,7 @@ verificationEmailSent: "Se le ha enviado un correo electrónico de confirmación
configuración."
notSet: "Sin especificar"
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"
pageLikedCount: "Número de favoritos de su página"
contact: "Contacto"
@ -975,7 +975,7 @@ shuffle: "Aleatorio"
account: "Cuentas"
move: "Mover"
_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
ligeramente la carga en el servidor."
sensitivity: "Sensibilidad de detección"
@ -1295,7 +1295,7 @@ _time:
_tutorial:
title: "Cómo usar Calckey"
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_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."
@ -1475,7 +1475,7 @@ _profile:
youCanIncludeHashtags: "Puedes añadir hashtags"
metadata: "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"
metadataContent: "Contenido"
changeAvatar: "Cambiar avatar"
@ -1789,7 +1789,7 @@ _pages:
splitStrByLine: "Separar texto en lineas"
_splitStrByLine:
arg1: "Texto"
ref: "Variables"
ref: "Variable"
aiScriptVar: "Variable de AiScript"
fn: "funciones"
_fn:
@ -1800,8 +1800,8 @@ _pages:
_for:
arg1: "Cantidad de repeticiones"
arg2: "Acción"
typeError: "El slot {slot} acepta el tipo {expect} pero fue ingresado el tipo
{actual}"
typeError: "El slot {slot} acepta el tipo \"{expect}\" pero fue ingresado el tipo
\"{actual}\""
thereIsEmptySlot: "El slot {slot} está vacío"
types:
string: "Texto"

View file

@ -1413,7 +1413,7 @@ _profile:
metadata: "Informations supplémentaires"
metadataEdit: "Éditer les 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"
metadataContent: "Contenu"
changeAvatar: "Changer l'image de profil"

View file

@ -1399,7 +1399,7 @@ _profile:
metadata: "Informasi tambahan"
metadataEdit: "Sunting 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"
metadataContent: "Isi"
changeAvatar: "Ubah avatar"

View file

@ -1266,7 +1266,7 @@ _profile:
metadata: "Informazioni aggiuntive"
metadataEdit: "Modifica informazioni aggiuntive"
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"
metadataContent: "Contenuto"
changeAvatar: "Modifica immagine profilo"

View file

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

View file

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

View file

@ -1404,7 +1404,7 @@ _profile:
metadata: "Dodatkowe informacje"
metadataEdit: "Edytuj dodatkowe informacje"
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"
metadataContent: "Treść"
changeAvatar: "Zmień awatar"

View file

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

View file

@ -1337,7 +1337,7 @@ _profile:
youCanIncludeHashtags: "Vo svojom bio môžete mať aj hashtagy."
metadata: "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"
metadataContent: "Obsah"
changeAvatar: "Zmeniť avatara"

File diff suppressed because it is too large Load diff

View file

@ -153,10 +153,11 @@ flagAsBotDescription: "Ввімкніть якщо цей обліковий з
Ця опція позначить обліковий запис як бота. Це потрібно щоб виключити безкінечну
інтеракцію між ботами а також відповідного підлаштування Calckey."
flagAsCat: "Акаунт кота"
flagAsCatDescription: "Ввімкніть, щоб позначити, що обліковий запис є котиком."
flagShowTimelineReplies: "Показувати відповіді на нотатки на часовій шкалі"
flagShowTimelineRepliesDescription: "Показує відповіді користувачів на нотатки інших
користувачів на часовій шкалі."
flagAsCatDescription: "Ввімкніть, щоб позначити, що обліковий запис є котиком, та
отримати котячі вуха!"
flagShowTimelineReplies: "Показувати відповіді на записи в стрічці"
flagShowTimelineRepliesDescription: "Показує відповіді користувачів на записи інших
користувачів у стрічці."
autoAcceptFollowed: "Автоматично приймати запити на підписку від користувачів, на
яких ви підписані"
addAccount: "Додати акаунт"
@ -169,7 +170,7 @@ removeWallpaper: "Прибрати шпалери"
searchWith: "Пошук: {q}"
youHaveNoLists: "У вас немає списків"
followConfirm: "Підписатися на {name}?"
proxyAccount: "Проксі-акаунт"
proxyAccount: "Обліковий запис проксі"
proxyAccountDescription: "Обліковий запис проксі це обліковий запис, який діє як
віддалений підписник для користувачів за певних умов. Наприклад, коли користувач
додає віддаленого користувача до списку, активність віддаленого користувача не буде
@ -217,7 +218,7 @@ blockedUsers: "Заблоковані користувачі"
noUsers: "Немає користувачів"
editProfile: "Редагувати обліковий запис"
noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?"
pinLimitExceeded: "Більше записів не можна закріпити"
pinLimitExceeded: "Ви не можете закріпити більше записів"
intro: "Встановлення Calckey завершено! Будь ласка, створіть обліковий запис адміністратора."
done: "Готово"
processing: "Обробка"
@ -232,7 +233,7 @@ all: "Всі"
subscribing: "Підписка"
publishing: "Публікація"
notResponding: "Не відповідає"
instanceFollowing: "Підписка на інстанс"
instanceFollowing: "Підписка на сервер"
instanceFollowers: "Підписники серверу"
instanceUsers: "Користувачі цього серверу"
changePassword: "Змінити пароль"
@ -359,7 +360,7 @@ pinnedUsersDescription: "Впишіть в список користувачів
\"Знайти\", ім'я в стовпчик."
pinnedPages: "Закріплені сторінки"
pinnedPagesDescription: "Введіть шляхи сторінок, які ви бажаєте закріпити на головній
сторінці цього інстанса, розділені новими рядками."
сторінці цього сервера, розділені новими рядками."
pinnedClipId: "Ідентифікатор закріпленої замітки"
pinnedNotes: "Закріплений запис"
hcaptcha: "hCaptcha"
@ -506,12 +507,14 @@ promote: "Виділити"
numberOfDays: "Кількість днів"
hideThisNote: "Сховати цей запис"
showFeaturedNotesInTimeline: "Показувати популярні записи у стрічці"
objectStorage: "Object Storage"
objectStorage: "Сховище"
useObjectStorage: "Використовувати object storage"
objectStorageBaseUrl: "Base URL"
objectStorageBaseUrlDesc: "Це початкова частина адреси, що використовується CDN або
проксі, наприклад для S3: https://<bucket>.s3.amazonaws.com, або GCS: 'https://storage.googleapis.com/<bucket>'"
objectStorageBucket: "Bucket"
objectStorageBaseUrl: "Базовий URL"
objectStorageBaseUrlDesc: "URL-адреса, що використовується як джерело. Вкажіть URL-адресу
вашого CDN або проксі-сервера, якщо ви їх використовуєте.\nДля S3 використовуйте
'https://<bucket>.s3.amazonaws.com', а для GCS або подібних сервісів - 'https://storage.googleapis.com/<bucket>',
тощо."
objectStorageBucket: "Сховище (Bucket)"
objectStorageBucketDesc: "Будь ласка вкажіть назву відра в налаштованому сервісі."
objectStoragePrefix: "Prefix"
objectStoragePrefixDesc: "Файли будуть зберігатись у розташуванні з цим префіксом."
@ -665,11 +668,11 @@ reportAbuse: "Поскаржитись"
reportAbuseOf: "Поскаржитись на {name}"
fillAbuseReportDescription: "Будь ласка вкажіть подробиці скарги. Якщо скарга стосується
запису, вкажіть посилання на нього."
abuseReported: "Дякуємо, вашу скаргу було відправлено. "
abuseReported: "Дякуємо. Ваш звіт було відправлено."
reporter: "Репортер"
reporteeOrigin: "Про кого повідомлено"
reporterOrigin: "Хто повідомив"
forwardReport: "Переслати звіт на віддалений інстанс"
forwardReport: "Переслати звіт на віддалений сервер"
forwardReportIsAnonymous: "Замість вашого облікового запису, анонімний системний обліковий
запис буде відображатися як доповідач на віддаленому сервері."
send: "Відправити"
@ -679,16 +682,16 @@ openInSideView: "Відкрити збоку"
defaultNavigationBehaviour: "Поведінка навігації за замовчуванням"
editTheseSettingsMayBreakAccount: "Зміна цих параметрів може призвести до пошкодження
вашого акаунта."
instanceTicker: "Мітка з назвою інстанса в нотатках"
instanceTicker: "Інформація про записи на сервері"
waitingFor: "Чекаємо на {x}"
random: "Випадковий"
system: "Система"
switchUi: "Інтерфейс"
desktop: "Десктоп"
clip: "Добірка"
clip: "Підбірка"
createNew: "Створити новий"
optional: "Необов'язково"
createNewClip: "Створити нотатку"
createNewClip: "Створити підбірку"
public: "Публічний"
i18nInfo: "Calckey перекладається на різні мови волонтерами. Ви можете допомогти за
посиланням: {link}."
@ -793,24 +796,33 @@ hide: "Сховати"
searchByGoogle: "Пошук"
indefinitely: "Ніколи"
file: "Файли"
reverse: "Перевернути"
reverse: "Переворот"
colored: "Кольоровий"
label: "Назва"
localOnly: "Локально"
_ffVisibility:
public: "Опублікувати"
private: Приватні
followers: Доступно тільки для підписників
_ad:
back: "Назад"
reduceFrequencyOfThisAd: Менше показувати цю рекламу
_gallery:
unlike: "Не вподобати"
liked: Вподобані записи
like: Подобається
my: Моя галерея
_email:
_follow:
title: "Новий підписник"
_receiveFollowRequest:
title: Ви отримали запит на підписку
_registry:
key: "Ключ"
keys: "Ключі"
domain: "Домен"
createKey: "Створити ключ"
scope: Область
_aboutMisskey:
about: "Misskey - це програмне забезпечення з відкритим кодом, яке розробляє syuilo
з 2014 року."
@ -822,12 +834,20 @@ _aboutMisskey:
morePatrons: "Ми дуже цінуємо підтримку багатьох інших помічників, не перелічених
тут. Дякуємо! 🥰"
patrons: "Підтримали"
patronsList: Перераховані в хронологічному порядку, а не за розміром пожертви. Зробіть
внесок за посиланням вище, щоб ваше ім'я було тут!
donateTitle: Сподобався Calckey?
pleaseDonateToCalckey: Будь ласка, підтримайте розробку Calckey.
pleaseDonateToHost: Також не забудьте підтримати ваш домашній сервер {host}, щоб
допомогти з його операційними витратами.
donateHost: Зробити внесок на рахунок {host}
sponsors: Спонсори Calckey
_nsfw:
respect: "Приховувати NSFW медіа"
ignore: "Не приховувати NSFW медіа"
force: "Приховувати всі медіа файли"
_mfm:
cheatSheet: " Довідка MFM"
cheatSheet: "Довідка MFM"
intro: "MFM це ексклюзивна мова розмітки тексту в Calckey, яку можна використовувати
в багатьох місцях. Тут ви можете переглянути приклади її синтаксису."
dummy: "Calckey розширює світ Федіверсу"
@ -839,35 +859,36 @@ _mfm:
url: "URL"
urlDescription: "Відображаються URL-адреси."
link: "Посилання"
linkDescription: "Окремі частини тексту можуть містити посилання"
linkDescription: "Окремі частини тексту можуть містити посилання."
bold: "Жирний шрифт"
boldDescription: "Виділяє літери, роблячи їх товще"
boldDescription: "Виділяє літери, роблячи їх товщими."
small: "Дрібний шрифт"
smallDescription: "Робить текст маленьким і тонким"
smallDescription: "Робить текст маленьким і тонким."
center: "По центру"
centerDescription: "Показує вміст у центрі"
centerDescription: "Показує вміст у центрі."
inlineCode: "Код (у рядку)"
inlineCodeDescription: "Показує фрагмент тексту у рядку як програмний код"
inlineCodeDescription: "Відображає підсвічування синтаксису для коду (програми)."
blockCode: "Код (блок)"
blockCodeDescription: "Показує кілька рядків тексту як блок програмного кода"
blockCodeDescription: "Відображає підсвічування синтаксису для багаторядкового (програмного)
коду в блоці."
inlineMath: "Формула (у рядку)"
inlineMathDescription: "Відображення математичних формул (KaTeX) у рядку"
blockMath: "Формули (блок)"
blockMathDescription: "Відображати багаторядкові формули (KaTeX) блоками"
blockMathDescription: "Відображати математичні формули (KaTeX) блоками"
quote: "Цитата"
quoteDescription: "Відображає зміст як цитату."
emoji: "Кастомні емоджі"
emojiDescription: "Щоб показати нетиповий емоджі, потрібно ввести його назву в двокрапках."
search: "Пошук"
searchDescription: "Відображає вікно пошуку з попередньо введеним текстом"
searchDescription: "Відображає вікно пошуку з попередньо введеним текстом."
flip: "Перевернути"
flipDescription: "Віддзеркалює вміст по горизонталі або вертикалі"
flipDescription: "Віддзеркалює вміст по горизонталі або вертикалі."
jelly: "Анімація (желе)"
jellyDescription: "Створює желеподібну анімацію"
jellyDescription: "Створює желеподібну анімацію."
tada: "Анімація (Тада!)"
tadaDescription: "Створює анімацію з відчуттям \"Тада!\""
tadaDescription: "Створює анімацію з відчуттям \"Тада!\"."
jump: "Анімація (стрибки)"
jumpDescription: "Показує стрибаючу анімацію"
jumpDescription: "Надає вмісту стрибучу анімацію."
bounce: "Анімація (пружина)"
shake: "Анімація (Shake)"
twitch: "Анімація (Twitch)"
@ -884,6 +905,36 @@ _mfm:
font: "Шрифт"
fontDescription: "Встановлює шрифт для контенту."
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:
none: "Не відображати"
remote: "Відображати для віддалених користувачів"
@ -892,6 +943,7 @@ _serverDisconnectedBehavior:
reload: "Автоматично перезавантажити"
dialog: "Показати діалогове вікно"
quiet: "Показати ненав’язливе попередження"
nothing: Нічого не робити
_channel:
create: "Створити канал"
edit: "Редагувати канал"
@ -900,22 +952,27 @@ _channel:
featured: "Тренди"
following: "Підписки"
usersCount: "{n} учасників"
notesCount: "{n} дописів"
notesCount: "{n} записів"
nameOnly: Тільки назва
nameAndDescription: Назва та опис
owned: Власні
_menuDisplay:
hide: "Сховати"
sideFull: Збоку
sideIcon: Збоку (тільки іконки)
top: Верх
_wordMute:
muteWords: "Заглушені слова"
muteWordsDescription: "Розділення ключових слів пробілами для \"І\" або з нової
лінійки для \"АБО\""
muteWordsDescription: "Відокремліть ключові слова пробілами для умови \"І\" або
з нового рядку для умови \"АБО\"."
muteWordsDescription2: "Для використання RegEx, ключові слова потрібно вписати поміж
слешів \"/\"."
softDescription: "Приховати записи які відповідають критеріям зі стрічки подій."
softDescription: "Приховати записи які відповідають критеріям зі стрічки."
hardDescription: "Приховати записи які відповідають критеріям зі стрічки подій.
Також приховані записи не будуть додані до стрічки подій навіть якщо критерії
буде змінено."
Також приховані записи не будуть додані до стрічки навіть якщо критерії буде змінено."
soft: "М'яко"
hard: "Жорстко"
mutedNotes: "Заблоковані нотатки"
mutedNotes: "Ігноровані записи"
_theme:
explore: "Оглянути теми"
install: "Встановити тему"
@ -979,9 +1036,20 @@ _theme:
accentDarken: "Акцент (Затемлений)"
accentLighten: "Акцент (Освітлений)"
fgHighlighted: "Виділений текст"
color: Колір
refProp: Посилання на властивість
alpha: Прозорість
constant: Стала
refConst: Посилання на сталу
key: Ключ
funcKind: Тип функції
darken: Затемнення
argument: Аргумент
basedProp: Початкова властивість
addConstant: Додати сталу
_sfx:
note: "Нотатки"
noteMy: "Мої нотатки"
note: "Новий запис"
noteMy: "Мої записи"
notification: "Сповіщення"
chat: "Чати"
chatBg: "Чати (фон)"
@ -991,7 +1059,7 @@ _ago:
future: "Майбутнє"
justNow: "Щойно"
secondsAgo: "{n}с тому"
minutesAgo: "{n}х тому"
minutesAgo: "{n}хв тому"
hoursAgo: "{n}г тому"
daysAgo: "{n}д тому"
weeksAgo: "{n} тиж. тому"
@ -1006,36 +1074,66 @@ _tutorial:
title: "Як використовувати Calckey"
step1_1: "Ласкаво просимо!"
step1_2: "Давайте налаштуємо вас. Ви будете працювати в найкоротші терміни!"
step2_1: "Спочатку, будь ласка, заповніть свій профіль"
step2_2: "Надавши деяку інформацію про себе, іншим людям буде легше зрозуміти, чи
step2_1: "Спочатку, будь ласка, заповніть свій профіль."
step2_2: "Після надання інформації про себе, іншим людям буде легше зрозуміти, чи
хочуть вони бачити ваші записи або стежити за вами."
step3_1: "Тепер настав час стежити за деякими людьми!"
step3_1: "Тепер настав час на когось підписатися!"
step3_2: "Ваша домашня і соціальна стрічки ґрунтуються на тому, за ким ви стежите,
тому для початку спробуйте стежити за кількома акаунтами.\nНатисніть на гурток
із плюсом у правому верхньому кутку профілю, щоб стежити за ним."
step4_1: "Давайте вийдемо на вас"
step4_1: "Давайте вийдемо на вас."
step4_2: "Для свого першого повідомлення деякі люди люблять робити {introduction}
повідомлення або просте \"Hello world!\""
step5_1: "Тимчасові рамки, скрізь тимчасові рамки!"
step5_2: "У вашому екземплярі включені {timelines} різних часових ліній."
step5_3: "Головна {icon} часова шкала - це шкала, де ви можете бачити повідомлення
ваших підписників."
step5_4: "Місцева {icon} тимчасова шкала - це шкала, де ви можете бачити повідомлення
всіх інших користувачів даного екземпляра"
step5_5: "Тимчасова шкала Рекомендовані {icon} - це шкала, де ви можете бачити повідомлення
від інстанцій, рекомендованих адміністраторами."
step5_6: "На часовій шкалі Social {icon} відображаються повідомлення від друзів
ваших підписників"
step5_7: "Глобальна {icon} часова шкала - це місце, де ви можете бачити повідомлення
від усіх інших підключених екземплярів"
step5_1: "Стрічки, скрізь одні стрічки!"
step5_2: "У вашому сервері включені {timelines} різні стрічки."
step5_3: "Головна {icon} стрічка - це стрічка, де ви можете бачити записи тих, на
кого ви підписалися."
step5_4: "Місцева {icon} стрічка - це стрічка, де ви можете бачити записи всіх інших
користувачів даного серверу."
step5_5: "Стрічка рекомендованих {icon} - це комбінація домашньої та місцевої стрічок."
step5_6: "На стрічці Рекомендованих {icon} ви можете бачити записи з серверів, які
рекомендують адміністратори."
step5_7: "Глобальна {icon} стрічка - це місце, де ви можете бачити записи від усіх
інших приєднаних серверів."
step6_1: "Отже, що це за місце?"
step6_2: "Ну, ви не просто приєдналися до Кальки. Ви приєдналися до порталу в Fediverse,
взаємопов'язаної мережі з тисяч серверів, званих \"інстансами\"."
step6_2: "Ну, ви не просто приєдналися до Calckey. Ви увійшли в Fediverse, взаємопов'язану
мережу з тисяч серверів."
step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але
цей працює! Це трохи складно, але ви швидко розберетеся"
цей працює! Це трохи складно, але ви швидко розберетеся."
step6_4: "Тепер ідіть, вивчайте і розважайтеся!"
_2fa:
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:
"read:account": "Переглядати дані профілю"
"write:account": "Змінити дані акаунту"
@ -1051,7 +1149,7 @@ _permissions:
"write:messaging": "Створювати та видаляти повідомлення"
"read:mutes": "Переглядати список ігнорованих"
"write:mutes": "Змінювати список ігнорованих"
"write:notes": "Писати і видаляти нотатки"
"write:notes": "Створення та видалення записів"
"read:notifications": "Переглядати сповіщення"
"read:reactions": "Переглядати реакції"
"write:reactions": "Змінювати реакції"
@ -1064,13 +1162,27 @@ _permissions:
"write:user-groups": "Змінювати групи користувача"
"read:channels": "Переглядати канали"
"write:channels": "Змінювати канали"
"read:gallery": Переглянути галерею
"write:gallery": Редагування галереї
"read:gallery-likes": Переглянути список вподобаних записів галереї
"write:notifications": Керування сповіщеннями
"write:gallery-likes": Редагувати список вподобаних записів галереї
_auth:
shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?"
shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?"
denied: "У доступі відмовлено"
allPermissions: Повний доступ до облікового запису
permissionAsk: 'Цей додаток запитує наступні дозволи:'
copyAsk: 'Будь ласка, вставте наступний код авторизації в додаток:'
pleaseGoBack: Будь ласка, поверніться до додатку
callback: Повернення до додатку
_antennaSources:
all: "Всі нотатки"
homeTimeline: "Нотатки тих, на кого ви підписані"
all: "Усі записи"
homeTimeline: "Записи тих, на кого ви підписані"
instances: Записи від усіх користувачів на сервері
userGroup: Записи від користувачів у вказаній групі
users: Записи обраних користувачів
userList: Дописи користувачів із вказаного списку
_weekday:
sunday: "Неділя"
monday: "Понеділок"
@ -1091,20 +1203,30 @@ _widgets:
photos: "Фото"
digitalClock: "Цифровий годинник"
federation: "Федіверс"
postForm: "Створення нотатки"
postForm: "Створення запису"
slideshow: "Слайд-шоу"
button: "Кнопка"
onlineUsers: "Користувачі онлайн"
jobQueue: "Черга завдань"
serverMetric: "Показники сервера "
serverMetric: "Показники сервера"
aiscript: "Консоль AiScript"
_userList:
chooseList: Оберіть список
meiliStatus: Стан сервера
meiliSize: Розмір індексу
rssTicker: RSS-тікер
instanceCloud: Хмара серверів
unixClock: Годинник UNIX
userList: Список користувачів
serverInfo: Інформація про сервер
meiliIndexCount: Індексовані записи
_cw:
hide: "Сховати"
show: "Показати більше"
chars: "{count} символів"
files: "{count} файлів"
_poll:
noOnlyOneChoice: "Потрібні принаймні два варіанти."
noOnlyOneChoice: "Потрібні принаймні два варіанти"
choiceN: "Варіант {n}"
noMore: "Більше варіантів додати не можна"
canMultipleVote: "Можна вибрати кілька варіантів"
@ -1127,19 +1249,19 @@ _poll:
remainingSeconds: "Залишилось {s} секунд"
_visibility:
public: "Публічний"
publicDescription: "Для всіх користувачів"
home: "Домівка"
publicDescription: "Ваш запис буде видно в усіх публічних стрічках"
home: "Домашній"
homeDescription: "Лише на домашній стрічці"
followers: "Підписники"
followersDescription: "Тільки для підписників"
followersDescription: "Зробити видимим тільки для ваших підписників і згаданих користувачів"
specified: "Особисто"
specifiedDescription: "Лише для певних користувачів"
localOnly: "Локально"
localOnlyDescription: "Приховано для віддалених користувачів"
_postForm:
replyPlaceholder: "Відповідь на цю нотатку..."
quotePlaceholder: "Прокоментуйте цю нотатку..."
channelPlaceholder: "Опублікувати в каналі"
replyPlaceholder: "Відповідь на цей запис..."
quotePlaceholder: "Прокоментуйте цей запис..."
channelPlaceholder: "Опублікувати в каналі..."
_placeholders:
a: "Чим займаєтесь?"
b: "Що відбувається навколо вас?"
@ -1155,51 +1277,65 @@ _profile:
metadata: "Додаткова інформація"
metadataEdit: "Редагувати додаткову інформацію"
metadataDescription: "Ви можете вказати до чотирьох пунктів додаткової інформації
у своєму профілі."
у своєму профілі. Ви можете додати тег {a} або {l} за допомогою {rel}, щоб підтвердити
посилання у своєму профілі!"
metadataLabel: "Назва"
metadataContent: "Вміст"
changeAvatar: "Змінити аватар"
changeBanner: "Змінити банер"
locationDescription: Якщо ви спочатку введете своє місто, іншим користувачам буде
показано ваш місцевий час.
_exportOrImport:
allNotes: "Всі нотатки"
allNotes: "Всі записи"
followingList: "Підписки"
muteList: "Ігнорувати"
blockingList: "Заблокувати"
userLists: "Списки"
excludeInactiveUsers: Вилучити неактивних користувачів
excludeMutingUsers: Вилучити заглушених користувачів
_charts:
federation: "Федіверс"
apRequest: "Запити"
usersTotal: "Загальна кількість користувачів"
activeUsers: "Активні користувачі"
notesTotal: "Загальна кількість нотаток"
notesTotal: "Загальна кількість записів"
filesIncDec: "Зміни кількості файлів"
filesTotal: "Загальна кількість файлів"
storageUsageIncDec: Різниця в використанні ємності диску
remoteNotesIncDec: Різниця в кількості віддалених записів
notesIncDec: Різниця в кількості записів
localNotesIncDec: Різниця в кількості локальних записів
storageUsageTotal: Загальне використання пам'яті
usersIncDec: Різниця в кількості користувачів
_instanceCharts:
requests: "Запити"
usersTotal: "Сумарна кількість користувачів"
notes: "Різниця кількості зроблених записів"
notesTotal: "Сумарна кількість нотаток"
ff: "Різниця кількості підписників"
notes: "Різниця в кількості зроблених записів"
notesTotal: "Сумарна кількість записів"
ff: "Різниця кількості підписників "
ffTotal: "Кількість підписників"
cacheSizeTotal: "Сумарний розмір кешу"
files: "Різниця в кількості файлів"
filesTotal: "Сумарна кількість файлів"
users: Різниця в кількості користувачів
cacheSize: Різниця в розмірі кешу
_timelines:
home: "Домівка"
local: "Локальна"
social: "Соціальна"
global: "Глобальна"
recommended: Рекомендована
_pages:
newPage: "Створити сторінку"
editPage: "Редагувати сторінку"
readPage: "Перегляд вихідного коду"
created: "Сторінка успішно створена."
updated: "Сторінка успішно оновлена."
created: "Сторінка успішно створена"
updated: "Сторінка успішно оновлена"
deleted: "Сторінку видалено"
pageSetting: "Налаштування сторінки"
nameAlreadyExists: "Вказана адреса сторінки вже існує."
invalidNameTitle: "Вказана адреса сторінки неприпустима."
invalidNameText: "Переконайтеся, що не залишили порожнім."
nameAlreadyExists: "Вказана адреса сторінки вже існує"
invalidNameTitle: "Вказана адреса сторінки неприпустима"
invalidNameText: "Переконайтеся, що поле заголовка сторінки не порожнє"
editThisPage: "Редагувати цю сторінку"
viewSource: "Переглянути вихідний код"
viewPage: "Переглянути свої сторінки"
@ -1242,6 +1378,7 @@ _pages:
_post:
text: "Вміст"
canvasId: "Ідентифікатор полотна"
attachCanvasImage: Прикріпити зображення полотна
textInput: "Введення тексту"
_textInput:
name: "Ім'я змінної"
@ -1262,10 +1399,10 @@ _pages:
id: "Ідентифікатор полотна"
width: "Ширина"
height: "Висота"
note: "Вбудована нотатка"
note: "Вбудований запис"
_note:
id: "Ідентифікатор нотатки"
idDescription: "Також можна вказати посилання на нотатку"
id: "Ідентифікатор запису"
idDescription: "Також можна вказати посилання на запис."
detailed: "Детальний вигляд"
switch: "Перемикач"
_switch:
@ -1456,7 +1593,7 @@ _pages:
arg1: "Текст"
ref: "Змінні"
aiScriptVar: "Змінна AiScript"
fn: "Функції"
fn: "Функція"
_fn:
slots: "Паз"
slots-info: "Використовувати нову лінію як роздільник пазів"
@ -1508,9 +1645,16 @@ _notification:
followRequestAccepted: "Прийняті підписки"
groupInvited: "Запрошення до груп"
app: "Сповіщення від додатків"
pollEnded: Опитування закінчено
_actions:
reply: "Відповісти"
renote: "Поширити"
renote: "Поширення"
followBack: також підписався на вас
emptyPushNotificationMessage: Push-сповіщення були оновлені
voted: проголосував на вашому опитуванні
renoted: поширив ваш запис
reacted: відреагував на ваш запис
pollEnded: Стали доступні результати опитування
_deck:
alwaysShowMainColumn: "Завжди показувати головну колонку"
columnAlign: "Вирівняти стовпці"
@ -1521,16 +1665,27 @@ _deck:
swapDown: "Пересунути вниз"
stackLeft: "У стовпчик вліво"
popRight: "Витягнути вправо"
profile: "Обліковий запис"
profile: "Простір"
_columns:
main: "Головна"
widgets: "Віджети"
notifications: "Сповіщення"
tl: "Стрічка"
antenna: "Антени"
antenna: "Антена"
list: "Списки"
mentions: "Згадки"
direct: "Особисте"
direct: "Особисті повідомлення"
channel: Канал
newProfile: Новий простір
introduction2: Натисніть на + у правій частині екрана, щоб додавати нові стовпці
по бажанню.
configureColumn: Налаштування стовпців
introduction: Створіть ідеальний інтерфейс для себе, вільно розташовуючи стовпці!
widgetsIntroduction: Будь ласка, виберіть "Редагувати віджети" в меню колонки і
додайте віджет.
renameProfile: Перейменувати простір
deleteProfile: Видалити простір
nameAlreadyExists: Простір із такою назвою вже існує.
removeReaction: Видалити вашу реакцію
renoteMute: Ігнорувати поширення
renoteUnmute: Показувати поширення
@ -1623,3 +1778,358 @@ usernameInfo: Ім'я, яке ідентифікує ваш обліковий
Ім'я користувача не може бути змінено пізніше.
noThankYou: Ні, дякую
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ử."
metadata: "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"
metadataContent: "Nội dung"
changeAvatar: "Đổi ảnh đại diện"

View file

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

View file

@ -984,6 +984,12 @@ _aboutMisskey:
donate: "贊助Calckey"
morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰"
patrons: "贊助者"
patronsList: 按時間順序列出,而不是按贊助規模列出。使用上面的連結贊助,在這裡獲得顯示您名字的機會!
sponsors: Calckey 贊助者們
donateTitle: 覺得 Calckey 棒嗎?
pleaseDonateToCalckey: 請考慮向 Calckey 贊助以支持其發展。
pleaseDonateToHost: 還請考慮捐贈給您在使用的伺服器 {host},以支援龐大的運營成本。
donateHost: 贊助給 {host}
_nsfw:
respect: "隱藏敏感內容"
ignore: "不隱藏敏感內容"
@ -1060,6 +1066,8 @@ _mfm:
position: 位置
alwaysPlay: 自動播放所有MFM動畫
positionDescription: 按指定數量移動內容。
advancedDescription: 如果禁用,則僅允許基本標記,除非正在播放 MFM 動畫
advanced: 高級MFM
_instanceTicker:
none: "隱藏"
remote: "向遠端使用者顯示"
@ -1202,14 +1210,14 @@ _tutorial:
step1_1: "歡迎!"
step1_2: "讓我們把你安排好。你很快就會啟動並運行!"
step2_1: "首先,請完成你的個人資料。"
step2_2: "通過提供一些關於你自己的資料,其他人會更容易了解他們是否想看到你的帖子或關注你。"
step2_2: "通過提供一些關於你自己的資料,其他人會更容易了解他們是否想看到你的貼文或關注你。"
step3_1: "現在是時候追隨一些人了!"
step3_2: "你的主頁和社交時間線是基於你所追蹤的人,所以試著先追蹤幾個帳戶。\n點擊個人資料右上角的加號圈就可以關注它。"
step4_1: "讓我們出去找你。"
step4_2: "對於他們的第一條信息,有些人喜歡做 {introduction} 或一個簡單的 \"hello world!\""
step5_1: "時間線,到處都是時間線!"
step5_2: "您的伺服器已啟用了{timelines}個時間線。"
step5_3: "首頁 {icon} 時間線是顯示你追蹤的帳號的帖子。"
step5_3: "首頁 {icon} 時間線是顯示你追蹤的帳號的貼文。"
step5_4: "本地 {icon} 時間線是你可以看到伺服器中所有其他用戶的貼文的時間線。"
step5_5: "社交 {icon} 時間線是你的 首頁時間線 和 本地時間線 的結合體。"
step5_6: "推薦 {icon} 時間線是顯示你的伺服器管理員推薦的貼文。"
@ -1361,7 +1369,7 @@ _profile:
youCanIncludeHashtags: "你也可以在「關於我」中加上 #tag。"
metadata: "進階資訊"
metadataEdit: "編輯進階資訊"
metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。"
metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。您可以添加帶有 {rel} 的 {a} 標籤或 {l} 標籤來驗證您個人資料上的鏈接!"
metadataLabel: "標籤"
metadataContent: "内容"
changeAvatar: "更換大頭貼"
@ -1820,12 +1828,12 @@ _experiments:
title: 試驗功能
findOtherInstance: 找找另一個伺服器
noGraze: 瀏覽器擴展 "Graze for Mastodon" 會與Calckey發生衝突請停用該擴展。
userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的帖子'
userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的貼文'
pushNotificationNotSupported: 你的瀏覽器或伺服器不支援推送通知
accessibility: 輔助功能
userSaysSomethingReasonReply: '{name} 回復了包含 {reason} 的帖子'
userSaysSomethingReasonReply: '{name} 回覆了包含 {reason} 的貼文'
hiddenTags: 隱藏主題標籤
indexPosts: 索引帖子
indexPosts: 索引貼文
indexNotice: 現在開始索引。 這可能需要一段時間,請不要在一個小時內重啟你的伺服器。
deleted: 已刪除
editNote: 編輯筆記
@ -1861,9 +1869,33 @@ audio: 音訊
sendPushNotificationReadMessageCaption: 包含文本 “{emptyPushNotificationMessage}” 的通知將顯示一小段時間。
這可能會增加您設備的電池使用量(如果適用)。
channelFederationWarn: 頻道功能尚未與聯邦宇宙連動
swipeOnMobile: 允許在頁面之間滑動
swipeOnMobile: 允許以滑動在頁面之間切換
sendPushNotificationReadMessage: 閱讀相關通知或消息後刪除推送通知
image: 圖片
seperateRenoteQuote: 分別獨立的轉傳及引用按鈕
clipsDesc: 摘錄就像一個可以分享的書籤。 你可以從每個貼文的菜單創建新摘錄或將貼文加入已有的摘錄。
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",
"version": "14.0.0-dev77",
"version": "14.0.0-dev83",
"codename": "aqua",
"repository": {
"type": "git",
@ -57,7 +57,7 @@
"gulp-replace": "1.1.4",
"gulp-terser": "2.1.0",
"install-peers": "^1.0.4",
"rome": "^12.1.3",
"rome": "^v12.1.3-nightly.f65b0d9",
"start-server-and-test": "1.15.2",
"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"
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]]
name = "console"
version = "0.15.7"
@ -486,6 +500,16 @@ dependencies = [
"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]]
name = "core-foundation-sys"
version = "0.8.4"
@ -1278,11 +1302,14 @@ dependencies = [
"futures",
"indicatif",
"native-utils",
"redis",
"sea-orm",
"sea-orm-migration",
"serde",
"serde_json",
"serde_yaml",
"tokio",
"url",
"urlencoding",
]
@ -1509,6 +1536,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "os_str_bytes"
version = "6.5.0"
@ -1843,6 +1876,29 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.2.16"
@ -2043,6 +2099,30 @@ dependencies = [
"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]]
name = "rustls-pemfile"
version = "1.0.2"
@ -2052,6 +2132,16 @@ dependencies = [
"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]]
name = "rustversion"
version = "1.0.12"
@ -2076,6 +2166,15 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "schemars"
version = "0.8.12"
@ -2286,6 +2385,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "semver"
version = "1.0.17"
@ -2370,6 +2492,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
[[package]]
name = "sha2"
version = "0.10.6"
@ -2518,7 +2646,7 @@ dependencies = [
"percent-encoding",
"rand",
"rust_decimal",
"rustls",
"rustls 0.20.8",
"rustls-pemfile",
"serde",
"serde_json",
@ -2564,7 +2692,7 @@ checksum = "804d3f245f894e61b1e6263c84b23ca675d96753b5abfd5cc8597d86806e8024"
dependencies = [
"once_cell",
"tokio",
"tokio-rustls",
"tokio-rustls 0.23.4",
]
[[package]]
@ -2778,11 +2906,21 @@ version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
dependencies = [
"rustls",
"rustls 0.20.8",
"tokio",
"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]]
name = "tokio-stream"
version = "0.1.14"
@ -2949,6 +3087,7 @@ dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]

View file

@ -9,7 +9,7 @@ members = ["migration"]
[features]
default = []
noarray = []
napi = ["dep:napi", "dep:napi-derive", "dep:radix_fmt"]
napi = ["dep:napi", "dep:napi-derive"]
[lib]
crate-type = ["cdylib", "lib"]
@ -31,11 +31,11 @@ serde_json = "1.0.96"
thiserror = "1.0.40"
tokio = { version = "1.28.1", features = ["full"] }
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
napi = { version = "2.13.1", default-features = false, features = ["napi6", "tokio_rt"], optional = true }
napi-derive = { version = "2.12.0", optional = true }
radix_fmt = { version = "1.0.0", optional = true }
[dev-dependencies]
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("9fbr9z0wbrjqyd3u", IdConvertType.MastodonId),
"3954607381600562394",
"2083785058661759970208986",
);
t.is(
convertId("9fbs680oyviiqrol9md73p8g", IdConvertType.MastodonId),
"3494513243013053824",
"5878598648988104013828532260828151168",
);
});
test("create cuid2 with timestamp prefix", (t) => {
nativeInitIdGenerator(16, "");
t.not(nativeCreateId(BigInt(Date.now())), nativeCreateId(BigInt(Date.now())));
t.is(nativeCreateId(BigInt(Date.now())).length, 16);
t.not(nativeCreateId(Date.now()), nativeCreateId(Date.now()));
t.is(nativeCreateId(Date.now()).length, 16);
});
test("create random string", (t) => {

View file

@ -21,6 +21,9 @@ futures = { version = "0.3.28", optional = true }
serde_yaml = "0.9.21"
serde = { version = "1.0.163", features = ["derive"] }
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]
version = "0.11.0"

View file

@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
mod m20230531_180824_drop_reversi;
mod m20230627_185451_index_note_url;
mod m20230709_000510_move_antenna_to_cache;
pub struct Migrator;
@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
vec![
Box::new(m20230531_180824_drop_reversi::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::*;
const DB_URL_ENV: &str = "DATABASE_URL";
const CACHE_URL_ENV: &str = "CACHE_URL";
const CACHE_PREFIX_ENV: &str = "CACHE_PREFIX";
#[cfg(feature = "convert")]
mod vec_to_json;
@ -15,8 +19,9 @@ async fn main() {
.expect("Failed to open '.config/default.yml'");
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(
"DATABASE_URL",
DB_URL_ENV,
format!(
"postgres://{}:{}@{}:{}/{}",
config.db.user,
@ -26,6 +31,36 @@ async fn main() {
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;
@ -34,13 +69,15 @@ async fn main() {
}
#[derive(Debug, PartialEq, Deserialize)]
#[serde(rename = "camelCase")]
#[serde(rename_all = "camelCase")]
pub struct Config {
pub url: url::Url,
pub db: DbConfig,
pub redis: RedisConfig,
pub cache_server: Option<RedisConfig>,
}
#[derive(Debug, PartialEq, Deserialize)]
#[serde(rename = "camelCase")]
pub struct DbConfig {
pub host: String,
pub port: u32,
@ -48,3 +85,23 @@ pub struct DbConfig {
pub user: 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",
"version": "napi version",
"format": "cargo fmt --all",
"lint": "cargo clippy --fix",
"cargo:test": "pnpm run cargo:unit && pnpm run cargo:integration",
"cargo:unit": "cargo test unit_test && cargo test -F napi unit_test",
"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_read;
pub mod antenna;
pub mod antenna_note;
pub mod app;
pub mod attestation_challenge;
pub mod auth_session;

View file

@ -15,6 +15,10 @@ pub struct Model {
pub image_url: Option<String>,
#[sea_orm(column_name = "updatedAt")]
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)]

View file

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

View file

@ -189,6 +189,10 @@ pub struct Model {
pub silenced_hosts: StringVec,
#[sea_orm(column_name = "experimentalFeatures", column_type = "JsonBinary")]
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)]

View file

@ -67,8 +67,6 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::antenna_note::Entity")]
AntennaNote,
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
@ -131,12 +129,6 @@ pub enum Relation {
UserNotePining,
}
impl Related<super::antenna_note::Entity> for Entity {
fn to() -> RelationDef {
Relation::AntennaNote.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
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_read::Entity as AnnouncementRead;
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::attestation_challenge::Entity as AttestationChallenge;
pub use super::auth_session::Entity as AuthSession;

View file

@ -1,9 +1,9 @@
use async_trait::async_trait;
use cfg_if::cfg_if;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use sea_orm::EntityTrait;
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::schema::Antenna;
@ -14,12 +14,6 @@ use super::Repository;
impl Repository<Antenna> for antenna::Model {
async fn pack(self) -> Result<Antenna, Error> {
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 {
None => None,
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()?,
user_list_id: self.user_list_id,
user_group_id,
users: self.users.into(),
users: self.users,
instances: self.instances.into(),
case_sensitive: self.case_sensitive,
notify: self.notify,
with_replies: self.with_replies,
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
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! {

View file

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

View file

@ -1,7 +1,10 @@
//! ID generation utility based on [cuid2]
use cfg_if::cfg_if;
use chrono::Utc;
use once_cell::sync::OnceCell;
use radix_fmt::radix_36;
use std::cmp;
use crate::impl_into_napi_error;
@ -14,47 +17,56 @@ impl_into_napi_error!(ErrorUninitialized);
static FINGERPRINT: OnceCell<String> = 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].
pub fn init_id(length: u16, fingerprint: impl Into<String>) {
FINGERPRINT.get_or_init(move || format!("{}{}", fingerprint.into(), cuid2::create_id()));
pub fn init_id<'a>(length: u16, fingerprint: &'a str) {
FINGERPRINT.get_or_init(move || format!("{}{}", fingerprint, cuid2::create_id()));
GENERATOR.get_or_init(move || {
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())
});
}
/// Returns Cuid2 with the length specified by [init_id]. Must be called after
/// [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() {
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! {
if #[cfg(feature = "napi")] {
use radix_fmt::radix_36;
use std::cmp;
use napi::bindgen_prelude::BigInt;
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].
#[napi]
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(cmp::max(length - TIMESTAMP_LENGTH, 8), fingerprint);
init_id(length, &fingerprint);
}
/// Generates
#[napi]
pub fn native_create_id(date_num: BigInt) -> String {
let time = cmp::max(date_num.get_u64().1 - TIME_2000, 0);
format!("{:0>8}{}", radix_36(time).to_string(), create_id().unwrap())
pub fn native_create_id(date_num: i64) -> String {
create_id(date_num).unwrap()
}
}
}
@ -62,37 +74,17 @@ cfg_if! {
#[cfg(test)]
mod unit_test {
use crate::util::id;
use cfg_if::cfg_if;
use pretty_assertions::{assert_eq, assert_ne};
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]
fn can_generate_unique_ids() {
assert_eq!(id::create_id(), Err(id::ErrorUninitialized));
id::init_id(12, "");
assert_eq!(id::create_id().unwrap().len(), 12);
assert_ne!(id::create_id().unwrap(), id::create_id().unwrap());
let id1 = thread::spawn(|| id::create_id().unwrap());
let id2 = thread::spawn(|| id::create_id().unwrap());
assert_eq!(id::create_id(0), Err(id::ErrorUninitialized));
id::init_id(16, "");
assert_eq!(id::create_id(0).unwrap().len(), 16);
assert_ne!(id::create_id(0).unwrap(), id::create_id(0).unwrap());
let id1 = thread::spawn(|| id::create_id(0).unwrap());
let id2 = thread::spawn(|| id::create_id(0).unwrap());
assert_ne!(id1.join().unwrap(), id2.join().unwrap());
}
}
}
}

View file

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

View file

@ -43,18 +43,16 @@ mod int_test {
keywords: vec![
vec!["foo".to_string(), "bar".to_string()],
vec!["foobar".to_string()],
]
.into(),
],
exclude_keywords: vec![
vec!["abc".to_string()],
vec!["def".to_string(), "ghi".to_string()],
]
.into(),
],
src: schema::AntennaSrc::All,
user_list_id: None,
user_group_id: None,
users: vec![].into(),
instances: vec![].into(),
users: vec![],
instances: vec![],
case_sensitive: true,
notify: true,
with_replies: false,
@ -95,7 +93,7 @@ mod int_test {
.unwrap()
.expect("note not found");
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(),
note_id: note_model.id.to_owned(),
read: false,

View file

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

View file

@ -54,9 +54,9 @@ export default function load() {
mixin.userAgent = `Calckey/${meta.version} (${config.url})`;
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)
config.cacheServer.prefix = mixin.host;
config.cacheServer.prefix = mixin.hostname;
return Object.assign(config, mixin);
}

View file

@ -1,7 +1,13 @@
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(
config.maxCaptionLength ?? 1500,
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 { ClipNote } from "@/models/entities/clip-note.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 { PromoRead } from "@/models/entities/promo-read.js";
import { Relay } from "@/models/entities/relay.js";
@ -168,7 +167,6 @@ export const entities = [
Clip,
ClipNote,
Antenna,
AntennaNote,
PromoNote,
PromoRead,
Relay,

View file

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

View file

@ -17,5 +17,5 @@ nativeInitIdGenerator(length, fingerprint);
* Ref: https://github.com/paralleldrive/cuid2#parameterized-length
*/
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.
* 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.

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;
@Column("varchar", {
length: 128,
length: 1024,
nullable: true,
})
public smtpUser: string | null;
@Column("varchar", {
length: 128,
length: 1024,
nullable: true,
})
public smtpPass: string | null;

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import * as fs from "node:fs";
import { queueLogger } from "../../logger.js";
import { addFile } from "@/services/drive/add-file.js";
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 type { Note } from "@/models/entities/note.js";
import type { Poll } from "@/models/entities/poll.js";
@ -75,7 +75,7 @@ export async function exportNotes(
if (note.hasPoll) {
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;
await write(isFirst ? content : ",\n" + content);
exportedNotesCount++;
@ -112,15 +112,16 @@ export async function exportNotes(
done();
}
function serialize(
async function serialize(
note: Note,
poll: Poll | null = null,
): Record<string, unknown> {
): Promise<Record<string, unknown>> {
return {
id: note.id,
text: note.text,
createdAt: note.createdAt,
fileIds: note.fileIds,
files: await DriveFiles.packMany(note.fileIds),
replyId: note.replyId,
renoteId: note.renoteId,
poll: poll,

View file

@ -3,6 +3,8 @@ import create from "@/services/note/create.js";
import { Users } from "@/models/index.js";
import type { DbUserImportMastoPostJobData } from "@/queue/types.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";
const logger = queueLogger.createSubLogger("import-calckey-post");
@ -29,10 +31,25 @@ export async function importCkPost(
done();
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 note = await create(user, {
createdAt: createdAt,
files: undefined,
files: files.length == 0 ? undefined : files,
poll: undefined,
text: text || undefined,
reply: null,

View file

@ -6,6 +6,8 @@ import type Bull from "bull";
import { htmlToMfm } from "@/remote/activitypub/misc/html-to-mfm.js";
import { resolveNote } from "@/remote/activitypub/models/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");
@ -43,9 +45,32 @@ export async function importMastoPost(
throw e;
}
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, {
createdAt: new Date(post.object.published),
files: undefined,
files: files.length == 0 ? undefined : files,
poll: undefined,
text: text || undefined,
reply,

View file

@ -1,4 +1,5 @@
import { downloadTextFile } from "@/misc/download-text-file.js";
import { processMastoNotes } from "@/misc/process-masto-notes.js";
import { Users, DriveFiles } from "@/models/index.js";
import type { DbUserImportPostsJobData } from "@/queue/types.js";
import { queueLogger } from "../../logger.js";
@ -30,6 +31,26 @@ export async function importPosts(
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);
try {

View file

@ -5,6 +5,7 @@ import { cleanCharts } from "./clean-charts.js";
import { checkExpiredMutings } from "./check-expired-mutings.js";
import { clean } from "./clean.js";
import { setLocalEmojiSizes } from "./local-emoji-size.js";
import { verifyLinks } from "./verify-links.js";
const jobs = {
tickCharts,
@ -13,6 +14,7 @@ const jobs = {
checkExpiredMutings,
clean,
setLocalEmojiSizes,
verifyLinks,
} as Record<
string,
| 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",
},
noKeywords: {
message: "No keywords",
message: "No keywords.",
code: "NO_KEYWORDS",
id: "aa975b74-1ddb-11ee-be56-0242ac120002",
},

View file

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

View file

@ -1,6 +1,8 @@
import define from "../../define.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 { generateVisibilityQuery } from "../../common/generate-visibility-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);
}
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(
Notes.createQueryBuilder("note"),
ps.sinceId,
@ -65,11 +87,7 @@ export default define(meta, paramDef, async (ps, user) => {
ps.sinceDate,
ps.untilDate,
)
.innerJoin(
AntennaNotes.metadata.targetName,
"antennaNote",
"antennaNote.noteId = note.id",
)
.where("note.id IN (:...noteIds)", { noteIds: noteIds })
.innerJoinAndSelect("note.user", "user")
.leftJoinAndSelect("user.avatar", "avatar")
.leftJoinAndSelect("user.banner", "banner")
@ -81,7 +99,6 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renote.user", "renoteUser")
.leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar")
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner")
.andWhere("antennaNote.antennaId = :antennaId", { antennaId: antenna.id })
.andWhere("note.visibility != 'home'");
generateVisibilityQuery(query, user);

View file

@ -12,7 +12,9 @@ import type { UserProfile } from "@/models/entities/user-profile.js";
import { notificationTypes } from "@/types.js";
import { normalizeForSearch } from "@/misc/normalize-for-search.js";
import { langmap } from "@/misc/langmap.js";
import { verifyLink } from "@/services/fetch-rel-me.js";
import { ApiError } from "../../error.js";
import config from "@/config/index.js";
import define from "../../define.js";
export const meta = {
@ -58,6 +60,18 @@ export const meta = {
code: "INVALID_REGEXP",
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: {
@ -234,16 +248,29 @@ export default define(meta, paramDef, async (ps, _user, token) => {
}
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
.filter(
(x) =>
typeof x.name === "string" &&
x.name !== "" &&
typeof x.value === "string" &&
x.value !== "",
)
.filter((x) => Object.keys(x).length !== 0)
.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 net from "node:net";
import { promises } from "node:dns";
import type Koa from "koa";
import sharp from "sharp";
import type { IImage } from "@/services/drive/image-processor.js";
@ -19,6 +21,40 @@ export async function proxyMedia(ctx: Koa.Context) {
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
const [path, cleanup] = await createTemp();

View file

@ -1,61 +1,24 @@
import type { Antenna } from "@/models/entities/antenna.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 { isUserRelated } from "@/misc/is-user-related.js";
import { publishAntennaStream, publishMainStream } from "@/services/stream.js";
import { redisClient } from "@/db/redis.js";
import { publishAntennaStream } from "@/services/stream.js";
import type { User } from "@/models/entities/user.js";
export async function addNoteToAntenna(
antenna: Antenna,
note: Note,
noteUser: { id: User["id"] },
_noteUser: { id: User["id"] },
) {
// 通知しない設定になっているか、自分自身の投稿なら既読にする
const read = !antenna.notify || antenna.userId === noteUser.id;
AntennaNotes.insert({
id: genId(),
antennaId: antenna.id,
noteId: note.id,
read: read,
});
redisClient.xadd(
`antennaTimeline:${antenna.id}`,
"MAXLEN",
"~",
"200",
"*",
"note",
note.id,
);
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 {
NoteUnreads,
AntennaNotes,
Users,
Followings,
ChannelFollowings,
@ -51,11 +50,11 @@ export default async function (
).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 readSpecifiedNotes: (Note | Packed<"Note">)[] = [];
const readChannelNotes: (Note | Packed<"Note">)[] = [];
const readAntennaNotes: (Note | Packed<"Note">)[] = [];
// const readAntennaNotes: (Note | Packed<"Note">)[] = [];
for (const note of notes) {
if (note.mentions?.includes(userId)) {
@ -68,22 +67,22 @@ export default async function (
readChannelNotes.push(note);
}
if (note.user != null) {
// たぶんnullになることは無いはずだけど一応
for (const antenna of myAntennas) {
if (
await checkHitAntenna(
antenna,
note,
note.user,
undefined,
Array.from(following),
)
) {
readAntennaNotes.push(note);
}
}
}
// if (note.user != null) {
// // たぶんnullになることは無いはずだけど一応
// for (const antenna of myAntennas) {
// if (
// await checkHitAntenna(
// antenna,
// note,
// note.user,
// undefined,
// Array.from(following),
// )
// ) {
// readAntennaNotes.push(note);
// }
// }
// }
}
if (
@ -141,33 +140,33 @@ export default async function (
});
}
if (readAntennaNotes.length > 0) {
await AntennaNotes.update(
{
antennaId: In(myAntennas.map((a) => a.id)),
noteId: In(readAntennaNotes.map((n) => n.id)),
},
{
read: true,
},
);
// if (readAntennaNotes.length > 0) {
// await AntennaNotes.update(
// {
// antennaId: In(myAntennas.map((a) => a.id)),
// noteId: In(readAntennaNotes.map((n) => n.id)),
// },
// {
// read: true,
// },
// );
// TODO: まとめてクエリしたい
for (const antenna of myAntennas) {
const count = await AntennaNotes.countBy({
antennaId: antenna.id,
read: false,
});
// // TODO: まとめてクエリしたい
// for (const antenna of myAntennas) {
// const count = await AntennaNotes.countBy({
// antennaId: antenna.id,
// read: false,
// });
if (count === 0) {
publishMainStream(userId, "readAntenna", antenna);
}
}
// if (count === 0) {
// publishMainStream(userId, "readAntenna", antenna);
// }
// }
Users.getHasUnreadAntenna(userId).then((unread) => {
if (!unread) {
publishMainStream(userId, "readAllAntennas");
}
});
}
// Users.getHasUnreadAntenna(userId).then((unread) => {
// if (!unread) {
// publishMainStream(userId, "readAllAntennas");
// }
// });
// }
}

View file

@ -38,7 +38,11 @@ export type UserDetailed = UserLite & {
createdAt: DateString;
description: string | null;
ffVisibility: "public" | "followers" | "private";
fields: { name: string; value: string }[];
fields: {
name: string;
value: string;
verified?: boolean;
}[];
followersCount: number;
followingCount: number;
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": {
"watch": "pnpm vite build --watch --mode development",
"build": "pnpm vite build",
"lint": "pnpm rome check \"src/**/*.{ts,vue}\"",
"format": "pnpm rome format * --write && pnpm prettier --write '**/*.{scss,vue}'"
"lint": "pnpm rome check **/*.ts --apply && pnpm run lint: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": {
"@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",
"@rollup/plugin-alias": "3.1.9",
"@rollup/plugin-json": "4.1.0",
@ -46,6 +49,8 @@
"date-fns": "2.30.0",
"emojilib": "github:thatonecalculator/emojilib",
"escape-regexp": "0.0.1",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-file-progress": "^1.3.0",
"eventemitter3": "5.0.1",
"fast-blurhash": "^1.1.2",
"focus-trap": "^7.5.2",
@ -57,6 +62,7 @@
"katex": "0.16.8",
"matter-js": "0.18.0",
"mfm-js": "0.23.3",
"paralint": "^1.2.1",
"photoswipe": "5.3.8",
"prettier": "3.0.0",
"prettier-plugin-vue": "1.1.6",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,11 +6,11 @@
</template>
<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 { i18n } from "@/i18n";
type Captcha = {
interface Captcha {
render(
container: string | Node,
options: {
@ -31,7 +31,7 @@ type Captcha = {
execute(id: string): void;
reset(id?: string): void;
getResponse(id: string): string;
};
}
type CaptchaProvider = "hcaptcha" | "recaptcha";
@ -105,7 +105,7 @@ function requestRender() {
captcha.value.render(captchaEl.value, {
sitekey: props.sitekey,
theme: defaultStore.state.darkMode ? "dark" : "light",
callback: callback,
callback,
"expired-callback": callback,
"error-callback": callback,
});

View file

@ -24,7 +24,8 @@
<script lang="ts" setup>
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";
const props = withDefaults(

View file

@ -8,30 +8,31 @@
</template>
<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 {
Chart,
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
LineController,
BarElement,
CategoryScale,
LinearScale,
TimeScale,
Chart,
Filler,
Legend,
LineController,
LineElement,
LinearScale,
PointElement,
SubTitle,
TimeScale,
Title,
Tooltip,
SubTitle,
Filler,
} from "chart.js";
import "chartjs-adapter-date-fns";
import { enUS } from "date-fns/locale";
import zoomPlugin from "chartjs-plugin-zoom";
// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114242002
// 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 { defaultStore } from "@/store";
import { useChartTooltip } from "@/scripts/use-chart-tooltip";
@ -92,7 +93,7 @@ Chart.register(
SubTitle,
Filler,
zoomPlugin,
//gradient,
// gradient,
);
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();
let chartInstance: Chart = null;
let chartData: {
let chartInstance: Chart = null,
chartData: {
series: {
name: string;
type: "line" | "area";
@ -140,7 +141,7 @@ let chartData: {
y: number;
}[];
}[];
} = null;
} = null;
const chartEl = ref<HTMLCanvasElement>(null);
const fetching = ref(true);
@ -210,7 +211,7 @@ const render = () => {
? x.color
: getColor(i)
: alpha(x.color ? x.color : getColor(i), 0.1),
/*gradient: props.bar ? undefined : {
/* gradient: props.bar ? undefined : {
backgroundColor: {
axis: 'y',
colors: {
@ -218,7 +219,7 @@ const render = () => {
[maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2),
},
},
},*/
}, */
barPercentage: 0.9,
categoryPercentage: 0.9,
fill: x.type === "area",
@ -271,7 +272,7 @@ const render = () => {
},
ticks: {
display: props.detailed,
//mirror: true,
// mirror: true,
},
},
},
@ -331,7 +332,7 @@ const render = () => {
},
}
: undefined,
//gradient,
// gradient,
},
},
plugins: [

View file

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

View file

@ -12,9 +12,9 @@
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from "vue";
import { onBeforeUnmount, onMounted } from "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 * as os from "@/os";
@ -27,13 +27,13 @@ const emit = defineEmits<{
(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(() => {
let left = props.ev.pageX + 1; // + 1
let top = props.ev.pageY + 1; // + 1
let left = props.ev.pageX + 1, // + 1
top = props.ev.pageY + 1; // + 1
const width = rootEl.offsetWidth;
const height = rootEl.offsetHeight;
@ -57,15 +57,11 @@ onMounted(() => {
rootEl.style.top = `${top}px`;
rootEl.style.left = `${left}px`;
for (const el of Array.from(document.querySelectorAll("body *"))) {
el.addEventListener("mousedown", onMousedown);
}
document.body.addEventListener("mousedown", onMousedown);
});
onBeforeUnmount(() => {
for (const el of Array.from(document.querySelectorAll("body *"))) {
el.removeEventListener("mousedown", onMousedown);
}
document.body.removeEventListener("mousedown", onMousedown);
});
function onMousedown(evt: Event) {

View file

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

View file

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

View file

@ -1,5 +1,6 @@
<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 { i18n } from "@/i18n";
import { defaultStore } from "@/store";
@ -51,7 +52,7 @@ export default defineComponent({
if (!slots || !slots.default) return;
const el = slots.default({
item: item,
item,
})[0];
if (el.key == null && item.id) el.key = item.id;

View file

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

View file

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

View file

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

View file

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

View file

@ -54,14 +54,19 @@
controls
@contextmenu.stop
>
<source :src="media.url" :type="media.type" />
<source :src="media.url" :type="mediaType" />
</video>
</VuePlyr>
</template>
<div class="buttons">
<button
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"
@click.stop="captionPopup"
>
@ -80,7 +85,7 @@
</template>
<script lang="ts" setup>
import { watch, ref } from "vue";
import { watch, ref, computed } from "vue";
import VuePlyr from "vue-plyr";
import "vue-plyr/dist/vue-plyr.css";
import type * as misskey from "calckey-js";
@ -107,6 +112,12 @@ const url =
? getStaticImageUrl(props.media.thumbnailUrl)
: props.media.thumbnailUrl;
const mediaType = computed(() => {
return props.media.type === "video/quicktime"
? "video/mp4"
: props.media.type;
});
function captionPopup() {
os.alert({
type: "info",

View file

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

View file

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

View file

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

View file

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

View file

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

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