Merge branch 'main' of github.com:binwiederhier/ntfy into ng/pwa-token-extend

This commit is contained in:
binwiederhier
2026-05-28 20:51:14 -04:00
93 changed files with 6055 additions and 1604 deletions
+552 -633
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -18,19 +18,15 @@
"@mui/material": "latest",
"dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1",
"humanize-duration": "^3.27.3",
"i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0",
"js-base64": "^3.7.2",
"i18next-http-backend": "^3.0.5",
"react": "latest",
"react-dom": "latest",
"react-i18next": "^11.16.2",
"react-infinite-scroll-component": "^6.1.0",
"react-remark": "^2.1.0",
"react-router-dom": "^6.2.2",
"stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2",
"stylis": "^4.3.0",
"stylis-plugin-rtl": "^2.1.1"
},
@@ -44,7 +40,7 @@
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8",
"vite": "^6.3.5",
"vite": "^6.4.2",
"vite-plugin-pwa": "^1.0.0"
},
"browserslist": {
+1
View File
@@ -0,0 +1 @@
{}
+29 -1
View File
@@ -12,5 +12,33 @@
"signup_form_username": "Nom d'usuari",
"signup_form_password": "Contrasenya",
"signup_form_confirm_password": "Confirma la contrasenya",
"signup_form_button_submit": "Dona't d'alta"
"signup_form_button_submit": "Dona't d'alta",
"signup_form_toggle_password_visibility": "Canvia la visibilitat de la contrasenya",
"signup_already_have_account": "Ja tens un compte? Inicia sessió!",
"signup_disabled": "Les inscripcions estan deshabilitades",
"signup_error_username_taken": "El nom d'usuari {{username}} ja està en ús",
"signup_error_creation_limit_reached": "Límit de creació de comptes assolit",
"login_title": "Inicia sessió al teu compte ntfy",
"login_form_button_submit": "Iniciar sessió",
"login_link_signup": "Crear compte",
"login_disabled": "L'accès està desactivat",
"action_bar_show_menu": "Mostrar el menú",
"action_bar_logo_alt": "logotip de ntfy",
"action_bar_change_display_name": "Canviar nom de pantalla",
"action_bar_reservation_add": "Reservar tema",
"action_bar_reservation_edit": "Canviar la reserva",
"action_bar_reservation_delete": "Eliminar la reserva",
"action_bar_reservation_limit_reached": "Límit assolit",
"action_bar_send_test_notification": "Enviar notificació de prova",
"action_bar_clear_notifications": "Esborrar totes les notificacions",
"action_bar_mute_notifications": "Silenciar notificacions",
"action_bar_unmute_notifications": "Reactivar notificacions",
"action_bar_unsubscribe": "Cancel·lar la subscripció",
"action_bar_toggle_mute": "Silenciar/reactivar notificacions",
"action_bar_toggle_action_menu": "Obrir/tancar el menú d'accions",
"action_bar_profile_settings": "Configuracions",
"action_bar_profile_logout": "Tancar sessió",
"action_bar_sign_in": "Iniciar sessió",
"action_bar_sign_up": "Crear compte",
"message_bar_type_message": "Escriu un missatge aquí"
}
+14
View File
@@ -215,6 +215,19 @@
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Call",
"account_basics_emails_title": "Email addresses",
"account_basics_emails_description": "For email notifications",
"account_basics_emails_no_emails_yet": "No verified emails yet",
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
"account_basics_emails_dialog_title": "Add email address",
"account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.",
"account_basics_emails_dialog_email_label": "Email address",
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
"account_basics_emails_dialog_verify_button": "Add email",
"account_basics_emails_dialog_code_label": "Verification code",
"account_basics_emails_dialog_code_placeholder": "e.g. 123456",
"account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired",
"account_basics_emails_dialog_check_verification_button": "Confirm",
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
"account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}",
@@ -238,6 +251,7 @@
"account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent",
"account_usage_calls_title": "Phone calls made",
"account_usage_emails_none": "No email notifications can be sent with this account",
"account_usage_calls_none": "No phone calls can be made with this account",
"account_usage_reservations_title": "Reserved topics",
"account_usage_reservations_none": "No reserved topics for this account",
+6
View File
@@ -0,0 +1,6 @@
{
"common_cancel": "Cancel",
"common_save": "Save",
"common_add": "Add",
"common_back": "Back"
}
+4 -4
View File
@@ -52,7 +52,7 @@
"publish_dialog_topic_placeholder": "Nombre del tópico, ej. phil_alerts",
"publish_dialog_title_label": "Título",
"publish_dialog_message_label": "Mensaje",
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, por ejemplo: warning, srv1-backup",
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, ej.: aviso, srv1-backup",
"publish_dialog_click_label": "Click URL",
"publish_dialog_click_placeholder": "URL que se abre cuando se hace click en la notificación",
"publish_dialog_email_label": "Email",
@@ -120,7 +120,7 @@
"publish_dialog_priority_low": "Prioridad baja",
"publish_dialog_priority_high": "Prioridad alta",
"publish_dialog_delay_label": "Retraso",
"publish_dialog_title_placeholder": "Título de la notificación, ej. Alerta de espacio en disco",
"publish_dialog_title_placeholder": "Título de la notificación, ej. \"Alerta de espacio en disco\"",
"publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.",
"publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_placeholder": "Nombre del archivo adjunto",
@@ -153,7 +153,7 @@
"priority_low": "baja",
"notifications_actions_not_supported": "Acción no soportada en la aplicación web",
"notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}",
"error_boundary_unsupported_indexeddb_description": "La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada. <br/> <br/> Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
"error_boundary_unsupported_indexeddb_description": "La aplicación web de ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada.<br/> <br/>Mismo que no sea ideal, tampoco tiene mucho sentido usar la aplicación web de ntfy en modo de navegación privada de todos modos, porque todo está guardado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
"action_bar_show_menu": "Mostrar menú",
"action_bar_logo_alt": "logo de ntfy",
"action_bar_toggle_action_menu": "Abrir/cerrar el menú de acción",
@@ -207,7 +207,7 @@
"action_bar_account": "Cuenta",
"action_bar_change_display_name": "Cambiar nombre de usuario",
"action_bar_reservation_add": "Reservar tema",
"action_bar_reservation_edit": "Modificar reserva",
"action_bar_reservation_edit": "Alterar la reserva",
"action_bar_reservation_delete": "Quitar reserva",
"action_bar_reservation_limit_reached": "Límite alcanzado",
"action_bar_profile_logout": "Cerrar sesión",
+4 -2
View File
@@ -307,7 +307,7 @@
"account_delete_dialog_label": "Password",
"account_upgrade_dialog_tier_features_no_reservations": "Nessun argomento riservato",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} messaggi giornalieri",
"account_upgrade_dialog_reservations_warning_one": "Il livello selezionato consente meno argomenti riservati rispetto al livello corrente. Prima di cambiare il livello, <strong> si prega di eliminare almeno una prenotazione</strong>. È possibile rimuovere le prenotazioni nel <Link>Impostazioni</Link>.",
"account_upgrade_dialog_reservations_warning_one": "Il livello selezionato consente meno argomenti riservati rispetto al livello corrente. Prima di cambiare il livello, <strong>si prega di eliminare almeno una prenotazione</strong>. È possibile rimuovere le prenotazioni nel <Link>Impostazioni</Link>.",
"alert_notification_permission_denied_title": "Le notifiche sono bloccate",
"alert_notification_permission_denied_description": "Per favore riabilitale nel tuo browser",
"subscribe_dialog_subscribe_use_another_background_info": "Le notifiche dagli altri server non saranno ricevute quando la web app non è in esecuzione",
@@ -403,5 +403,7 @@
"web_push_subscription_expiring_body": "Apri ntfy per continuare a ricevere notifiche",
"web_push_unknown_notification_title": "Notifica sconosciuta ricevuta dal server",
"account_tokens_dialog_expires_x_hours": "Il token scade tra {{hours}} ore",
"prefs_reservations_table": "Tabella argomenti riservati"
"prefs_reservations_table": "Tabella argomenti riservati",
"account_basics_cannot_edit_or_delete_provisioned_user": "Un utente autorizzato non può essere modificato o eliminato",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossibile modificare o eliminare il token fornito"
}
+1
View File
@@ -0,0 +1 @@
{}
+125
View File
@@ -0,0 +1,125 @@
{
"common_cancel": "Atcelt",
"common_save": "Saglabāt",
"common_add": "Pievienot",
"common_back": "Atpakaļ",
"signup_form_username": "Lietotājvārds",
"signup_form_password": "Parole",
"action_bar_settings": "Iestatījumi",
"action_bar_account": "Konts",
"action_bar_profile_title": "Profils",
"action_bar_profile_settings": "Iestatījumi",
"action_bar_profile_logout": "Iziet",
"nav_button_account": "Konts",
"nav_button_settings": "Iestatījumi",
"nav_button_documentation": "Dokumentācija",
"nav_button_connecting": "savienojas",
"notifications_list_item": "Paziņojums",
"notifications_delete": "Dzēst",
"notifications_tags": "Birkas",
"notifications_example": "Piemērs",
"publish_dialog_title_label": "Virsraksts",
"publish_dialog_message_label": "Ziņojums",
"publish_dialog_tags_label": "Birkas",
"publish_dialog_priority_label": "Prioritāte",
"publish_dialog_email_label": "E-pasta adrese",
"publish_dialog_filename_label": "Datnes nosaukums",
"publish_dialog_delay_label": "Aizkave",
"publish_dialog_button_cancel": "Atcelt",
"publish_dialog_button_send": "Sūtīt",
"subscribe_dialog_subscribe_button_cancel": "Atcelt",
"subscribe_dialog_subscribe_button_subscribe": "Abonēt",
"subscribe_dialog_login_password_label": "Parole",
"subscribe_dialog_error_user_anonymous": "anonīms lietotājs",
"account_basics_title": "Konts",
"account_basics_username_title": "Lietotājvārds",
"account_basics_password_title": "Parole",
"account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu",
"account_basics_phone_numbers_dialog_channel_call": "Zvanīt",
"account_usage_title": "Lietojums",
"account_usage_unlimited": "Neierobežots",
"account_basics_tier_admin": "Administrators",
"account_basics_tier_basic": "Pamata",
"account_basics_tier_free": "Bezmaksas",
"account_basics_tier_interval_monthly": "ikmēnesi",
"account_basics_tier_interval_yearly": "katru gadu",
"account_basics_tier_change_button": "Mainīt",
"account_delete_dialog_label": "Parole",
"account_delete_dialog_button_cancel": "Atcelt",
"account_upgrade_dialog_interval_monthly": "Ikmēnesi",
"account_upgrade_dialog_interval_yearly": "Katru gadu",
"account_upgrade_dialog_tier_price_per_month": "mēnesī",
"account_upgrade_dialog_tier_selected_label": "Atlasīts",
"account_upgrade_dialog_tier_current_label": "Pašreizējais",
"account_upgrade_dialog_button_cancel": "Atcelt",
"account_tokens_table_token_header": "Pilnvara",
"account_tokens_table_expires_header": "Derīgs līdz",
"account_tokens_dialog_button_cancel": "Atcelt",
"prefs_notifications_title": "Paziņojumi",
"prefs_notifications_delete_after_never": "Nekad",
"prefs_notifications_web_push_disabled": "Atspējots",
"prefs_users_table_user_header": "Lietotājs",
"prefs_users_dialog_password_label": "Parole",
"prefs_appearance_title": "Izskats",
"prefs_appearance_language_title": "Valoda",
"prefs_appearance_theme_title": "Motīvs",
"prefs_reservations_table_topic_header": "Tēma",
"prefs_reservations_table_access_header": "Piekļuve",
"prefs_reservations_dialog_topic_label": "Tēma",
"prefs_reservations_dialog_access_label": "Piekļuve",
"priority_min": "minimālā",
"priority_low": "zema",
"priority_default": "noklusējuma",
"priority_high": "augsta",
"priority_max": "maksimālā",
"signup_form_confirm_password": "Atkārtot paroli",
"signup_form_button_submit": "Izveidot kontu",
"login_link_signup": "Izveidot kontu",
"action_bar_show_menu": "Rādīt izvēlni",
"action_bar_logo_alt": "ntfy logotips",
"action_bar_reservation_add": "Rezervēt tēmu",
"action_bar_reservation_edit": "Mainīt rezervāciju",
"action_bar_reservation_delete": "Noņemt rezervāciju",
"action_bar_reservation_limit_reached": "Sasniegts limits",
"action_bar_mute_notifications": "Apklusināt paziņojumus",
"action_bar_sign_up": "Izveidot kontu",
"message_bar_publish": "Publicēt ziņojumu",
"nav_topics_title": "Abonētās tēmas",
"nav_button_all_notifications": "Visi paziņojumi",
"nav_button_publish_message": "Publicēt paziņojumu",
"nav_button_muted": "Paziņojumi apklusināti",
"alert_notification_permission_required_button": "Dot tagad",
"notifications_list": "Paziņojumu saraksts",
"notifications_priority_x": "{{priority}} prioritāte",
"notifications_new_indicator": "Jauns paziņojums",
"notifications_attachment_image": "Pielikuma attēls",
"notifications_attachment_copy_url_button": "Kopēt URL adresi",
"notifications_attachment_open_button": "Atvērt pielikumu",
"notifications_attachment_file_image": "attēla datne",
"notifications_attachment_file_video": "video datne",
"notifications_attachment_file_audio": "audio datne",
"notifications_attachment_file_document": "cits datnes tips",
"notifications_click_copy_url_button": "Kopēt saiti",
"notifications_click_open_button": "Atvērt saiti",
"notifications_actions_failed_notification": "Neveiksmīga darbība",
"publish_dialog_title_no_topic": "Publicēt paziņojumu",
"publish_dialog_progress_uploading": "Augšupielādē …",
"publish_dialog_message_published": "Paziņojums publicēts",
"publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi",
"publish_dialog_priority_min": "Minimāla prioritāte",
"publish_dialog_priority_low": "Zema prioritāte",
"publish_dialog_priority_default": "Noklusējuma prioritāte",
"publish_dialog_priority_high": "Augsta prioritāte",
"publish_dialog_priority_max": "Maksimāla prioritāte",
"publish_dialog_base_url_label": "Pakalpojuma URL adrese",
"publish_dialog_topic_label": "Tēmas nosaukums",
"publish_dialog_topic_reset": "Atiestatīt tēmu",
"publish_dialog_click_label": "Klikšķināma URL adrese",
"publish_dialog_call_label": "Tālruņa zvans",
"publish_dialog_attach_label": "Pielikuma URL adrese",
"publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums",
"publish_dialog_other_features": "Citas funkcijas:",
"publish_dialog_chip_call_label": "Tālruņa zvans",
"publish_dialog_chip_delay_label": "Aizkavēt piegādi",
"publish_dialog_chip_topic_label": "Mainīt tēmu"
}
+5 -1
View File
@@ -92,5 +92,9 @@
"notifications_click_open_button": "Отвори линк",
"notifications_actions_open_url_title": "Оди на {{url}}",
"notifications_actions_not_supported": "Дејството не е поддржано во веб-апликацијата",
"notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}"
"notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}",
"notifications_none_for_any_title": "Не сте добиле никакви известувања.",
"notifications_actions_failed_notification": "Неуспешно дејство",
"notifications_none_for_topic_title": "Сè уште не сте добиле никакви известувања за оваа тема.",
"publish_dialog_filename_label": "Име на фајл"
}
+1
View File
@@ -0,0 +1 @@
{}
+6 -4
View File
@@ -2,7 +2,7 @@
"action_bar_clear_notifications": "Limpar todas as notificações",
"action_bar_send_test_notification": "Enviar notificação de teste",
"action_bar_unsubscribe": "Anular subscrição",
"action_bar_toggle_mute": "Ativa/Desativa notificações",
"action_bar_toggle_mute": "Ativar/Desativar notificações",
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
"message_bar_type_message": "Escreva uma mensagem aqui",
"message_bar_error_publishing": "Erro ao publicar notificação",
@@ -70,11 +70,11 @@
"publish_dialog_topic_label": "Nome do tópico",
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo: \"avisos_do_filipe\"",
"publish_dialog_topic_reset": "Limpar tópico",
"publish_dialog_title_placeholder": "Título da notificação, por exemplo: \"Alerta de espaço em disco\"",
"publish_dialog_title_placeholder": "Título da notificação, p.ex: \"Alerta de espaço em disco\"",
"publish_dialog_message_label": "Mensagem",
"publish_dialog_message_placeholder": "Escreva uma mensagem aqui",
"publish_dialog_tags_label": "Etiquetas",
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup",
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por vírgula, p.ex.: aviso, srv1-backup",
"publish_dialog_priority_label": "Prioridade",
"publish_dialog_click_label": "URL de clique",
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
@@ -404,5 +404,7 @@
"web_push_subscription_expiring_title": "As notificações serão pausadas",
"web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações",
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web"
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido"
}
+7 -5
View File
@@ -59,11 +59,11 @@
"publish_dialog_topic_label": "Nome do tópico",
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts",
"publish_dialog_title_label": "Título",
"publish_dialog_title_placeholder": "Título da notificação, por exemplo Alerta de espaço em disco",
"publish_dialog_title_placeholder": "Título da notificação, p.ex.: \"Alerta de espaço em disco\"",
"publish_dialog_message_label": "Mensagem",
"publish_dialog_message_placeholder": "Digite uma mensagem aqui",
"publish_dialog_tags_label": "Etiquetas",
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup",
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, p.ex.: aviso, srv1-backup",
"publish_dialog_priority_label": "Prioridade",
"publish_dialog_click_label": "Clique em URL",
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
@@ -112,7 +112,7 @@
"common_add": "Adicionar",
"common_save": "Salvar",
"prefs_appearance_title": "Aparência",
"prefs_appearance_language_title": "LInguagem",
"prefs_appearance_language_title": "Idioma",
"priority_min": "minima",
"priority_low": "baixa",
"priority_default": "padrão",
@@ -120,7 +120,7 @@
"priority_max": "máxima",
"error_boundary_title": "Ah não, ntfy parou de funcionar",
"error_boundary_gathering_info": "Coletar mais informações …",
"error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.<br/>Se tiver um minuto, por favor <githubLink> relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isso.<br/>Se tiver um minuto, por favor <githubLink>relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Copiar rastreamento de pilha",
"error_boundary_stack_trace": "Rastreamento de pilha",
"publish_dialog_attachment_limits_file_and_quota_reached": "excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante",
@@ -404,5 +404,7 @@
"web_push_subscription_expiring_title": "As notificações serão pausadas",
"web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações",
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web"
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado"
}
+12 -12
View File
@@ -9,7 +9,7 @@
"notifications_none_for_any_description": "Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.",
"notifications_no_subscriptions_title": "Похоже, что у вас ещё нет подписок.",
"alert_notification_permission_required_description": "Предоставьте браузеру разрешение на отображение уведомлений на рабочем столе",
"notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.",
"notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.",
"notifications_example": "Пример",
"notifications_more_details": "Для более подробной информации, посетите <websiteLink>наш сайт</websiteLink> или <docsLink>документацию</docsLink>.",
"notifications_loading": "Идет загрузка уведомлений …",
@@ -66,9 +66,9 @@
"notifications_click_open_button": "Открыть ссылку",
"subscribe_dialog_subscribe_title": "Подписаться на тему",
"publish_dialog_button_cancel": "Отмена",
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки Вы сможете отправлять уведомления используя PUT/POST-запросы.",
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы сможете отправлять уведомления используя PUT/POST-запросы.",
"prefs_users_description": "Вы можете управлять пользователями для защищённых тем. Учтите, что имя учётные данные хранятся в локальном хранилище браузера.",
"error_boundary_description": "Это не должно было случиться. Нам очень жаль. <br/>Если Вы можете уделить минуту своего времени, пожалуйста <githubLink>сообщите об этом на GitHub</githubLink>, или дайте нам знать через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"error_boundary_description": "Это не должно было случиться. Нам очень жаль. <br/>Если вы можете уделить минуту своего времени, пожалуйста <githubLink>сообщите об этом на GitHub</githubLink>, или дайте нам знать через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com",
"publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_label": "Имя файла",
@@ -155,19 +155,19 @@
"action_bar_show_menu": "Показать меню",
"action_bar_logo_alt": "Логотип ntfy",
"emoji_picker_search_clear": "Сбросить поиск",
"account_upgrade_dialog_cancel_warning": "Это действие <strong>отменит Вашу подписку</strong> и переведет Вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше <strong>будут удалены</strong>.",
"account_upgrade_dialog_cancel_warning": "Это действие <strong>отменит вашу подписку</strong> и переведет вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше <strong>будут удалены</strong>.",
"account_tokens_table_create_token_button": "Создать токен доступа",
"account_tokens_table_last_origin_tooltip": "С IP-адреса {{ip}}, нажмите для подробностей",
"account_tokens_dialog_title_edit": "Изменить токен доступа",
"account_delete_dialog_button_cancel": "Отмена",
"account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У Вас не будет доступа к порталу оплаты.",
"account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У вас не будет доступа к порталу оплаты.",
"account_delete_dialog_description": "Это действие безвозвратно удалит вашу учётную запись, включая все данные, хранящиеся на сервере. После удаления имя пользователя вашей учётной записи не будет доступно для регистрации в течение 7 дней. Если вы точно хотите продолжить, пожалуйста, введите свой пароль ниже.",
"account_delete_dialog_label": "Пароль",
"reservation_delete_dialog_action_keep_description": "Сообщения и вложения которые находятся в кэше сервера станут доступны всем, кто знает имя темы.",
"prefs_reservations_table": "Список зарезервированных тем",
"prefs_reservations_table_access_header": "Доступ",
"prefs_reservations_table_everyone_write_only": "Я могу публиковать и подписываться, все остальные могут публиковать",
"prefs_reservations_dialog_description": "Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.",
"prefs_reservations_dialog_description": "Резервирование дает вам возможность управлять темой и настраивать правила доступа к ней для пользователей.",
"reservation_delete_dialog_action_delete_title": "Удалить сообщения в кэше и вложения",
"reservation_delete_dialog_action_delete_description": "Сообщения в кэше и вложения будут безвозвратно удалены. Это действие невозможно отменить.",
"prefs_reservations_table_not_subscribed": "Не подписан",
@@ -178,10 +178,10 @@
"prefs_reservations_dialog_title_delete": "Удалить резервирование",
"prefs_reservations_dialog_title_edit": "Изменение резервированной темы",
"prefs_reservations_table_topic_header": "Тема",
"prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с Вашей учетной записью.",
"prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с вашей учетной записью.",
"prefs_users_delete_button": "Удалить пользователя",
"prefs_users_table_cannot_delete_or_edit": "Невозможно удалить или редактировать залогиненного пользователя",
"account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы одну зарезервированную тему</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
"account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы одну зарезервированную тему</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
"account_upgrade_dialog_proration_info": "<strong>Пересчёт оплаты</strong>: при расширении подписки, разница в цене от текущей <strong>спишется сразу</strong>. При упрощении подписки, неиспользованные средства пойдут в оплату баланса по следующим счетам.",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл",
"account_tokens_table_never_expires": "Никогда",
@@ -191,7 +191,7 @@
"error_boundary_unsupported_indexeddb_title": "Работа в приватном режиме не поддерживается",
"account_tokens_dialog_button_create": "Создать токен",
"account_tokens_delete_dialog_submit_button": "Безвозвратно удалить токен",
"account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы {{count}} зарезервированных тем</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
"account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы {{count}} зарезервированных тем</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} сообщений в день",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} суммарный объем",
"account_upgrade_dialog_tier_selected_label": "Выбранная",
@@ -268,7 +268,7 @@
"notifications_attachment_file_document": "другой тип файла",
"notifications_actions_not_supported": "Действие не поддерживается в веб-приложении",
"display_name_dialog_title": "Изменить псевдоним",
"display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке Ваших подписок. Это помогает легче находить темы со сложными именами.",
"display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке ваших подписок. Это помогает легче находить темы со сложными именами.",
"reserve_dialog_checkbox_label": "Зарезервировать тему и настроить доступ",
"publish_dialog_emoji_picker_show": "Выбрать смайлик",
"publish_dialog_click_reset": "Удалить ссылку",
@@ -312,7 +312,7 @@
"account_upgrade_dialog_button_cancel_subscription": "Отменить подписку",
"account_upgrade_dialog_button_update_subscription": "Изменить подписку",
"account_tokens_title": "Токены доступа",
"account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные Вашей учетной записи. Смотрите <Link>документацию</Link> чтобы узнать больше.",
"account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные вашей учетной записи. Смотрите <Link>документацию</Link> чтобы узнать больше.",
"account_tokens_table_token_header": "Токен",
"account_tokens_table_label_header": "Название",
"account_tokens_table_last_access_header": "Последний доступ",
@@ -340,7 +340,7 @@
"account_basics_password_dialog_confirm_password_label": "Подтвердите пароль",
"account_basics_password_dialog_button_submit": "Сменить пароль",
"account_basics_tier_title": "Тип учётной записи",
"error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается Вашим браузером в приватном режиме.<br/><br/>Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в <githubLink>этом отчете на GitHub</githubLink> или связавшись с нами через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается вашим браузером в приватном режиме.<br/><br/>Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в <githubLink>этом отчете на GitHub</githubLink> или связавшись с нами через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"account_basics_tier_interval_monthly": "ежемесячно",
"account_basics_tier_interval_yearly": "ежегодно",
"account_upgrade_dialog_interval_yearly": "Ежегодно",
+116
View File
@@ -0,0 +1,116 @@
{
"common_cancel": "Prekliči",
"common_save": "Shrani",
"common_add": "Dodaj",
"common_back": "Nazaj",
"common_copy_to_clipboard": "Kopiraj v odložišče",
"signup_title": "Ustvari ntfy račun",
"signup_form_username": "Uporabniško ime",
"signup_form_password": "Geslo",
"signup_form_confirm_password": "Potrditev gesla",
"signup_form_button_submit": "Registracija",
"signup_form_toggle_password_visibility": "Prikaži geslo",
"signup_already_have_account": "Že imate račun? Prijavite se!",
"signup_disabled": "Registracija je onemogočena",
"signup_error_username_taken": "Uporabniško ime {{username}} je zasedeno",
"signup_error_creation_limit_reached": "Omejitev registracije novih računov je presežena",
"login_title": "Prijava v vaš ntfy račun",
"login_form_button_submit": "Prijava",
"login_link_signup": "Registracija",
"login_disabled": "Prijava je onemogočena",
"action_bar_show_menu": "Prikaži menu",
"action_bar_logo_alt": "ntfy logotip",
"action_bar_settings": "Nastavitve",
"action_bar_account": "Račun",
"action_bar_change_display_name": "Spremenite prikazno ime",
"action_bar_reservation_add": "Rezerviraj temo",
"action_bar_reservation_edit": "Spremenite rezervacijo",
"action_bar_reservation_delete": "Odstranite rezervacijo",
"action_bar_reservation_limit_reached": "Omejitev dosežena",
"action_bar_send_test_notification": "Pošljite testno obvestilo",
"action_bar_clear_notifications": "Počistite vsa obvestila",
"action_bar_mute_notifications": "Izklopite zvok obvestil",
"action_bar_unmute_notifications": "Vklopite zvok obvestil",
"action_bar_unsubscribe": "Odjava",
"action_bar_toggle_mute": "Vklopite/izklopite zvok obvestil",
"action_bar_toggle_action_menu": "Odprite/zaprite akcijski menu",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Nastavitve",
"action_bar_profile_logout": "Odjavite se",
"action_bar_sign_in": "Prijavite se",
"action_bar_sign_up": "Registrirajte se",
"message_bar_type_message": "Vpišite sporočilo",
"message_bar_error_publishing": "Napaka pri objavi obvestila",
"message_bar_show_dialog": "Prikaži pogovorno okno za objavo",
"message_bar_publish": "Objavi sporočilo",
"nav_topics_title": "Naročene teme",
"nav_button_all_notifications": "Vsa obvestila",
"nav_button_account": "Račun",
"nav_button_settings": "Nastavitve",
"nav_button_documentation": "Dokumentacija",
"nav_button_publish_message": "Objavi obvestilo",
"publish_dialog_title_no_topic": "Objavi obvestilo",
"publish_dialog_progress_uploading": "Nalaganje …",
"publish_dialog_progress_uploading_detail": "Nalaganje {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Obvestilo objavljeno",
"publish_dialog_attachment_limits_file_and_quota_reached": "presega {{fileSizeLimit}} omejitev velikosti datoteke in kvote, {{remainingBytes}} še preostalo",
"publish_dialog_attachment_limits_file_reached": "presega {{fileSizeLimit}} omejitev velikosti datoteke",
"publish_dialog_attachment_limits_quota_reached": "presega kvoto, {{remainingBytes}} še preostalo",
"publish_dialog_emoji_picker_show": "Izberite emoji",
"publish_dialog_priority_min": "Najnižja prioriteta",
"publish_dialog_priority_low": "Nizka prioriteta",
"publish_dialog_priority_default": "Privzeta prioriteta",
"publish_dialog_priority_high": "Visoka prioriteta",
"publish_dialog_priority_max": "Najvišja prioriteta",
"publish_dialog_base_url_label": "URL storitve",
"publish_dialog_base_url_placeholder": "URL storitve, npr. https://example.com",
"publish_dialog_topic_label": "Naziv teme",
"publish_dialog_topic_placeholder": "Naziv teme, npr. janez_alarmi",
"publish_dialog_topic_reset": "Ponastavitev temo",
"publish_dialog_title_label": "Naslov",
"publish_dialog_title_placeholder": "Naslov obvestila, npr. Primanjkuje prostora",
"publish_dialog_message_label": "Sporočilo",
"publish_dialog_message_placeholder": "Vpišite sporočilo",
"publish_dialog_tags_label": "Značke",
"publish_dialog_tags_placeholder": "Z vejico ločen seznam značk, npr. opozorilo, srv1-kopija",
"publish_dialog_priority_label": "Prioriteta",
"publish_dialog_click_label": "URL za klik",
"publish_dialog_click_placeholder": "URL ki se odpre, ko kliknete na obvestilo",
"publish_dialog_click_reset": "Odstranite URL za klik",
"publish_dialog_email_label": "E-naslov",
"publish_dialog_email_placeholder": "E-naslov za posredovanje obvestil, npr. janez@example.com",
"publish_dialog_email_reset": "Odstranite e-naslov za posredovanje",
"publish_dialog_call_label": "Telefonski klic",
"publish_dialog_call_item": "Pokličite telefonsko številko {{number}}",
"publish_dialog_call_reset": "Odstranite telefonski klic",
"publish_dialog_attach_label": "URL priponke",
"publish_dialog_attach_placeholder": "Pripnite datoteko preko URL povezave, npr. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Odstranite URL priponke",
"publish_dialog_filename_label": "Ime datoteke",
"publish_dialog_filename_placeholder": "Ime priponke",
"publish_dialog_delay_label": "Zamik",
"publish_dialog_delay_placeholder": "Zamik dostave, npr. {{unixTimestamp}}, {{relativeTime}}, ali \"{{naturalLanguage}}\" (v angleškem jeziku)",
"publish_dialog_delay_reset": "Odstranite zamik dostave",
"publish_dialog_other_features": "Ostale funkcije:",
"publish_dialog_chip_click_label": "URL za klik",
"publish_dialog_chip_email_label": "Posredujte na e-naslov",
"publish_dialog_chip_call_label": "Telefonski klic",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Ni verificiranih telefonskih številk",
"publish_dialog_chip_attach_url_label": "Pripnite datoteko preko URL",
"publish_dialog_chip_attach_file_label": "Pripnite lokalno datoteko",
"publish_dialog_chip_delay_label": "Zamik dostave",
"publish_dialog_chip_topic_label": "Spremenite temo",
"publish_dialog_details_examples_description": "Za vzorčne primere in natančnejše opise vseh funkcij pošiljanja se posvetujte z <docsLink>dokumentacijo</docsLink>.",
"publish_dialog_button_cancel_sending": "Prekličite pošiljanje",
"publish_dialog_button_cancel": "Prekliči",
"publish_dialog_button_send": "Pošlji",
"publish_dialog_checkbox_markdown": "Oblikovanje kot Markdown",
"publish_dialog_checkbox_publish_another": "Objavite še eno",
"publish_dialog_attached_file_title": "Priponka:",
"publish_dialog_attached_file_filename_placeholder": "Ime priponke",
"publish_dialog_attached_file_remove": "Odstranite priponko",
"publish_dialog_drop_file_here": "Povleci in spusti",
"emoji_picker_search_placeholder": "Išči emoji",
"emoji_picker_search_clear": "Ponastavi iskanje",
"subscribe_dialog_subscribe_title": "Naročite se na temo"
}
+8 -6
View File
@@ -11,7 +11,7 @@
"nav_button_muted": "Сповіщення вимкнено",
"nav_button_connecting": "підключення",
"alert_notification_permission_required_title": "Сповіщення вимкнено",
"alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення.",
"alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення на робочому столі",
"alert_notification_permission_required_button": "Дозволити",
"alert_not_supported_title": "Сповіщення не підтримуються",
"notifications_list_item": "Сповіщення",
@@ -34,11 +34,11 @@
"publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts",
"publish_dialog_topic_reset": "Скинути тему",
"publish_dialog_title_label": "Заголовок",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад Сповіщення про дисковий простір",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостатньо місця на диску",
"publish_dialog_message_label": "Повідомлення",
"publish_dialog_message_placeholder": "Введіть повідомлення",
"publish_dialog_tags_label": "Теги",
"publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад warning, srv1-backup",
"publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад, warning, srv1-backup",
"publish_dialog_click_placeholder": "URL-адреса, яка відкривається після натискання сповіщення",
"publish_dialog_email_label": "Електронна пошта",
"publish_dialog_attach_placeholder": "Прикріпіть файл за URL-адресою, наприклад https://f-droid.org/F-Droid.apk",
@@ -300,7 +300,7 @@
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище",
"account_upgrade_dialog_tier_current_label": "Поточний",
"account_upgrade_dialog_tier_selected_label": "Вибране",
"account_upgrade_dialog_cancel_warning": "Це <strong> скасує вашу підписку</strong> і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері <strong>, буде видалено</strong>.",
"account_upgrade_dialog_cancel_warning": "Ця дія <strong>скасує вашу підписку</strong>, і знизить версію вашого облікового запису {{date}}. Відповідно, в цю дату, резервування тем, а також повідомлення, кешовані на сервері, <strong>буде видалено</strong>.",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми",
"account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день",
@@ -397,12 +397,14 @@
"prefs_notifications_web_push_disabled_description": "Сповіщення надходитимуть якщо вебзастосунок запущений (за допомоги WebSocket)",
"prefs_notifications_web_push_enabled": "Увімкнено для {{server}}",
"prefs_notifications_web_push_disabled": "Вимкнено",
"prefs_appearance_theme_title": "Тема",
"prefs_appearance_theme_title": "Тема оформлення",
"prefs_appearance_theme_system": "Система (за замовчуванням)",
"prefs_appearance_theme_light": "Світлий режим",
"error_boundary_button_reload_ntfy": "Перезавантажити ntfy",
"web_push_subscription_expiring_title": "Сповіщення буде призупинено",
"web_push_subscription_expiring_body": "Відкрийте ntfy, щоб продовжити отримувати сповіщення",
"web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку",
"alert_notification_ios_install_required_title": "потрібно встановити на iOS"
"alert_notification_ios_install_required_title": "Необхідне встановлення на iOS",
"account_basics_cannot_edit_or_delete_provisioned_user": "Автоматично створеного користувача не можна редагувати чи видалити",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити"
}
+41 -2
View File
@@ -2,6 +2,8 @@ import i18n from "i18next";
import {
accountBillingPortalUrl,
accountBillingSubscriptionUrl,
accountEmailUrl,
accountEmailVerifyUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
@@ -135,8 +137,8 @@ class AccountApi {
token,
label,
};
if (expires > 0) {
body.expires = Math.floor(Date.now() / 1000) + expires;
if (expires >= 0) {
body.expires = expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0;
}
console.log(`[AccountApi] Creating user access token ${url}`);
await fetchOrThrow(url, {
@@ -340,6 +342,43 @@ class AccountApi {
});
}
async verifyEmail(email) {
const url = accountEmailVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending email verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
}),
});
}
async addEmail(email, code) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Adding email with verification code ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
code,
}),
});
}
async deleteEmail(email) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Deleting email ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
}),
});
}
async sync() {
try {
if (!session.token()) {
+10
View File
@@ -47,6 +47,14 @@ export class IncorrectPasswordError extends Error {
}
}
export class EmailVerificationCodeInvalidError extends Error {
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
constructor() {
super("Email verification code invalid or expired");
}
}
export const throwAppError = async (response) => {
if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response);
@@ -63,6 +71,8 @@ export const throwAppError = async (response) => {
throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
throw new EmailVerificationCodeInvalidError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
+1 -1
View File
@@ -35,7 +35,7 @@ export const formatMessage = (m) => {
return m.message || "";
};
const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
export const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
export const isImage = (attachment) => {
if (!attachment) return false;
+38 -5
View File
@@ -1,4 +1,3 @@
import { Base64 } from "js-base64";
import beep from "../sounds/beep.mp3";
import juntos from "../sounds/juntos.mp3";
import pristine from "../sounds/pristine.mp3";
@@ -34,6 +33,8 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
@@ -61,9 +62,14 @@ export const unmatchedTags = (tags) => {
return tags.filter((tag) => !(tag in emojisMapped));
};
export const encodeBase64 = (s) => Base64.encode(s);
export const encodeBase64 = (s) => {
const bytes = new TextEncoder().encode(s);
let binary = "";
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
};
export const encodeBase64Url = (s) => Base64.encodeURI(s);
export const encodeBase64Url = (s) => encodeBase64(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
export const bearerAuth = (token) => `Bearer ${token}`;
@@ -136,9 +142,10 @@ export const hashCode = (s) => {
/**
* convert `i18n.language` style str (e.g.: `en_US`) to kebab-case (e.g.: `en-US`),
* which is expected by `<html lang>` and `Intl.DateTimeFormat`
* which is expected by `<html lang>` and `Intl.DateTimeFormat`. Falls back to "en"
* if the input is missing or not a string.
*/
export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-");
export const getKebabCaseLangStr = (language) => (typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en");
export const formatShortDateTime = (timestamp, language) =>
new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
@@ -149,6 +156,32 @@ export const formatShortDateTime = (timestamp, language) =>
export const formatShortDate = (timestamp, language) =>
new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000));
export const formatShortDuration = (ms, language) => {
const seconds = Math.round(ms / 1000);
const units = [
{ unit: "year", s: 31536000 },
{ unit: "month", s: 2592000 },
{ unit: "week", s: 604800 },
{ unit: "day", s: 86400 },
{ unit: "hour", s: 3600 },
{ unit: "minute", s: 60 },
{ unit: "second", s: 1 },
];
const match = units.find((u) => seconds >= u.s) ?? units[units.length - 1];
const value = Math.round(seconds / match.s);
// [lang, "en"] makes Intl fall back to English for well-formed-but-unsupported tags;
// the try/catch covers malformed tags (RangeError) so the web app never crashes here.
try {
return new Intl.NumberFormat([getKebabCaseLangStr(language), "en"], {
style: "unit",
unit: match.unit,
unitDisplay: "long",
}).format(value);
} catch {
return new Intl.NumberFormat("en", { style: "unit", unit: match.unit, unitDisplay: "long" }).format(value);
}
};
export const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return "0 bytes";
const k = 1024;
+286 -86
View File
@@ -39,13 +39,12 @@ import EditIcon from "@mui/icons-material/Edit";
import { Trans, useTranslation } from "react-i18next";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import humanizeDuration from "humanize-duration";
import CelebrationIcon from "@mui/icons-material/Celebration";
import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
import routes from "./routes";
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
@@ -53,7 +52,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles";
import { IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { ProChip } from "./SubscriptionPopup";
import session from "../app/Session";
@@ -84,6 +83,7 @@ const Basics = () => {
<PrefGroup>
<Username />
<ChangePassword />
<Emails />
<PhoneNumbers />
<AccountType />
</PrefGroup>
@@ -354,6 +354,200 @@ const AccountType = () => {
);
};
const Emails = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const labelId = "prefVerifiedEmails";
const handleDialogOpen = () => {
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
};
const handleCopy = (email) => {
copyToClipboard(email);
setSnackOpen(true);
};
const handleDelete = async (email) => {
try {
await accountApi.deleteEmail(email);
} catch (e) {
console.log(`[Account] Error deleting email`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
}
}
};
if (!config.enable_email_verify) {
return null;
}
if (account?.limits.emails === 0) {
return (
<Pref
title={
<>
{t("account_basics_emails_title")}
{config.enable_payments && <ProChip />}
</>
}
description={t("account_basics_emails_description")}
>
<em>{t("account_usage_emails_none")}</em>
</Pref>
);
}
return (
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<div aria-labelledby={labelId}>
{account?.emails?.map((email) => (
<Chip
key={email}
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{email}</span>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(email)}
onDelete={() => handleDelete(email)}
/>
))}
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<AddIcon />
</IconButton>
</div>
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_basics_emails_copied_to_clipboard")}
/>
</Portal>
</Pref>
);
};
const AddEmailDialog = (props) => {
const theme = useTheme();
const { t } = useTranslation();
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const verifyEmail = async () => {
try {
setSending(true);
await accountApi.verifyEmail(email);
setVerificationCodeSent(true);
} catch (e) {
console.log(`[Account] Error sending email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const checkVerifyEmail = async () => {
try {
setSending(true);
await accountApi.addEmail(email, code);
props.onClose();
} catch (e) {
console.log(`[Account] Error confirming email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else if (e instanceof EmailVerificationCodeInvalidError) {
setError(t("account_basics_emails_dialog_code_invalid"));
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyEmail();
} else {
await checkVerifyEmail();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
{!verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_email_label")}
aria-label={t("account_basics_emails_dialog_email_label")}
placeholder={t("account_basics_emails_dialog_email_placeholder")}
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
fullWidth
variant="standard"
/>
)}
{verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_code_label")}
aria-label={t("account_basics_emails_dialog_code_label")}
placeholder={t("account_basics_emails_dialog_code_placeholder")}
type="text"
value={code}
onChange={(ev) => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
variant="standard"
/>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
</Button>
</DialogFooter>
</Dialog>
);
};
const PhoneNumbers = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
@@ -701,10 +895,7 @@ const Stats = () => {
title={t("account_usage_attachment_storage_title")}
description={t("account_usage_attachment_storage_description", {
filesize: formatBytes(account.limits.attachment_file_size),
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
language: i18n.resolvedLanguage,
fallbacks: ["en"],
}),
expiry: formatShortDuration(account.limits.attachment_expiry_duration * 1000, i18n.resolvedLanguage),
})}
>
<div>
@@ -750,7 +941,9 @@ const Stats = () => {
)}
</PrefGroup>
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
<Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography>
<Typography variant="body1" sx={{ pt: 3 }}>
{t("account_usage_basis_ip_description")}
</Typography>
)}
</Card>
);
@@ -859,87 +1052,94 @@ const TokensTable = (props) => {
</TableRow>
</TableHead>
<TableBody>
{tokens.map((token) => (
<TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
aria-label={t("account_tokens_table_token_header")}
>
<span>
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
...
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
<IconButton onClick={() => handleCopy(token.token)}>
<ContentCopy />
</IconButton>
</Tooltip>
</span>
</TableCell>
<TableCell aria-label={t("account_tokens_table_label_header")}>
{token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
{token.token !== session.token() && (token.label || "-")}
</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
<div style={{ display: "flex", alignItems: "center" }}>
<span>{formatShortDateTime(token.last_access, i18n.language)}</span>
<Tooltip
title={t("account_tokens_table_last_origin_tooltip", {
ip: token.last_origin,
})}
>
<IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
<Public />
</IconButton>
</Tooltip>
</div>
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{token.token !== session.token() && !token.provisioned && (
<>
<Tooltip title={t("account_tokens_dialog_title_edit")}>
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
<EditIcon />
{tokens.map((token) => {
const hasLastAccess = Number.isFinite(token.last_access) && token.last_access > 0;
const hasLastOrigin = !!token.last_origin;
return (
<TableRow key={token.token} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell
component="th"
scope="row"
sx={{ paddingLeft: 0, whiteSpace: "nowrap" }}
aria-label={t("account_tokens_table_token_header")}
>
<span>
<span style={{ fontFamily: "Monospace", fontSize: "0.9rem" }}>{token.token.slice(0, 12)}</span>
...
<Tooltip title={t("common_copy_to_clipboard")} placement="right">
<IconButton onClick={() => handleCopy(token.token)}>
<ContentCopy />
</IconButton>
</Tooltip>
<Tooltip title={t("account_tokens_dialog_title_delete")}>
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
<CloseIcon />
</IconButton>
</span>
</TableCell>
<TableCell aria-label={t("account_tokens_table_label_header")}>
{token.token === session.token() && <em>{t("account_tokens_table_current_session")}</em>}
{token.token !== session.token() && (token.label || "-")}
</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_expires_header")}>
{token.expires ? formatShortDateTime(token.expires, i18n.language) : <em>{t("account_tokens_table_never_expires")}</em>}
</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }} aria-label={t("account_tokens_table_last_access_header")}>
<div style={{ display: "flex", alignItems: "center" }}>
{hasLastAccess ? <span>{formatShortDateTime(token.last_access, i18n.language)}</span> : <em>-</em>}
{hasLastOrigin && (
<Tooltip
title={t("account_tokens_table_last_origin_tooltip", {
ip: token.last_origin,
})}
>
<IconButton onClick={() => openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}>
<Public />
</IconButton>
</Tooltip>
)}
</div>
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{token.token !== session.token() && !token.provisioned && (
<>
<Tooltip title={t("account_tokens_dialog_title_edit")}>
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("account_tokens_dialog_title_delete")}>
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
<CloseIcon />
</IconButton>
</Tooltip>
</>
)}
{token.token === session.token() && (
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
<span>
<IconButton disabled>
<EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span>
</Tooltip>
</>
)}
{token.token === session.token() && (
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit")}>
<span>
<IconButton disabled>
<EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span>
</Tooltip>
)}
{token.provisioned && (
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
<span>
<IconButton disabled>
<EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
)}
{token.provisioned && (
<Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
<span>
<IconButton disabled>
<EditIcon />
</IconButton>
<IconButton disabled>
<CloseIcon />
</IconButton>
</span>
</Tooltip>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
<Portal>
<Snackbar
+17 -11
View File
@@ -31,18 +31,24 @@ const AttachmentIcon = (props) => {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_document");
}
const icon = (
<Box
component="img"
src={imageFile}
alt={imageLabel}
loading="lazy"
sx={{
width: "28px",
height: "28px",
}}
/>
);
if (!props.href) {
return icon;
}
return (
<Link href={props.href} target="_blank">
<Box
component="img"
src={imageFile}
alt={imageLabel}
loading="lazy"
sx={{
width: "28px",
height: "28px",
}}
/>
<Link href={props.href} target="_blank" rel="noopener noreferrer">
{icon}
</Link>
);
};
+9 -29
View File
@@ -1,6 +1,5 @@
import * as React from "react";
import StackTrace from "stacktrace-js";
import { CircularProgress, Link, Button } from "@mui/material";
import { Link, Button } from "@mui/material";
import { Trans, withTranslation } from "react-i18next";
import { copyToClipboard } from "../app/utils";
@@ -9,8 +8,7 @@ class ErrorBoundaryImpl extends React.Component {
super(props);
this.state = {
error: false,
originalStack: null,
niceStack: null,
stack: null,
unsupportedIndexedDB: false,
};
}
@@ -32,23 +30,17 @@ class ErrorBoundaryImpl extends React.Component {
}
handleError(error, info) {
// Immediately render original stack trace
const prettierOriginalStack = info.componentStack
const componentStack = info.componentStack
.trim()
.split("\n")
.map((line) => ` at ${line}`)
.join("\n");
const parts = [error.toString()];
if (error.stack) parts.push(error.stack);
parts.push(componentStack);
this.setState({
error: true,
originalStack: `${error.toString()}\n${prettierOriginalStack}`,
});
// Fetch additional info and a better stack trace
StackTrace.fromError(error).then((stack) => {
console.error("[ErrorBoundary] Stacktrace fetched", stack);
const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
const niceStack = `${error.toString()}\n${stackString}`;
this.setState({ niceStack });
stack: parts.join("\n"),
});
}
@@ -60,12 +52,7 @@ class ErrorBoundaryImpl extends React.Component {
}
copyStack() {
let stack = "";
if (this.state.niceStack) {
stack += `${this.state.niceStack}\n\n`;
}
stack += `${this.state.originalStack}\n`;
copyToClipboard(stack);
copyToClipboard(`${this.state.stack}\n`);
}
renderUnsupportedIndexedDB() {
@@ -112,14 +99,7 @@ class ErrorBoundaryImpl extends React.Component {
</Button>
</div>
<h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack ? (
<pre>{this.state.niceStack}</pre>
) : (
<>
<CircularProgress size="20px" sx={{ verticalAlign: "text-bottom" }} /> {t("error_boundary_gathering_info")}
</>
)}
<pre>{this.state.originalStack}</pre>
<pre>{this.state.stack}</pre>
</div>
);
}
+2 -1
View File
@@ -117,7 +117,8 @@ const NavList = (props) => {
const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
const hasTier = !!account?.tier;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid && !hasTier;
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());
const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());
+2 -1
View File
@@ -30,6 +30,7 @@ import priority3 from "../img/priority-3.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils";
import { imageRegex } from "../app/notificationUtils";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
@@ -805,7 +806,7 @@ const AttachmentBox = (props) => {
borderRadius: "4px",
}}
>
<AttachmentIcon type={file.type} href={URL.createObjectURL(file)} />
<AttachmentIcon type={file.type} href={imageRegex.test(file.name) ? URL.createObjectURL(file) : undefined} />
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
<ExpandingTextField
minWidth={140}