diff --git a/_locales/az/messages.json b/_locales/az/messages.json index f6cdd86f5f..69eef62a88 100644 --- a/_locales/az/messages.json +++ b/_locales/az/messages.json @@ -15,7 +15,6 @@ "actualSize": "Aktual həcmi", "add": "Əlavə et", "addAdmin": "{count, plural, one [Admin əlavə et] other [Adminləri əlavə et]}", - "addAdmins": "İnzibatçılar əlavə et", "addAdminsDescription": "Admin edəcəyiniz istifadəçinin Hesab ID-sini daxil edin.

Birdən çox istifadəçi əlavə etmək üçün vergüllə ayrılmış hər Hesab ID-sini daxil edin. Bir dəfəyə 20-yə qədər Hesab ID-si daxil edilə bilər.", "adminCannotBeDemoted": "Adminlərin vəzifəsini azaltmaq və ya adminləri qrupdan xaric etmək mümkün deyil.", "adminCannotBeRemoved": "Adminlər xaric edilə bilməz.", @@ -457,6 +456,7 @@ "failedResendInvite": "{group_name} qrupundakı {name} üçün dəvət təkrar göndərilmədi", "failedResendInviteMultiple": "{group_name} qrupundakı {name}digər {count} nəfər üçün dəvət təkrar göndərilmədi", "failedResendInviteTwo": "{group_name} qrupundakı {name}{other_name} üçün dəvət təkrar göndərilmədi", + "failedResendPromotionTwo": "{group_name} qrupundakı {name}{other_name} üçün dəvət təkrar göndərilmədi", "failedToDownload": "Endirmək uğursuz oldu", "failures": "Xətalar", "feedback": "Əks-əlaqə", diff --git a/_locales/ca/messages.json b/_locales/ca/messages.json index 4a02e030eb..6d4a1b540e 100644 --- a/_locales/ca/messages.json +++ b/_locales/ca/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Aquest és el vostre ID de compte. Els altres usuaris el poden escanejar per a encetar una conversa amb vós.", "actualSize": "Mida actual", "add": "Afegir", - "addAdmins": "Afegiu administradors", "addAdminsDescription": "Introdueix l'ID del compte de l'usuari que estàs promocionant a administrador.

Per afegir diversos usuaris, introdueix cada ID del compte separat per una coma. Es poden especificar fins a 20 identificadors de compte alhora.", "adminCannotBeRemoved": "Els administradors no es poden eliminar.", "adminMorePromotedToAdmin": "{name} i {count} altres han estat ascendits a Admin.", diff --git a/_locales/cs/messages.json b/_locales/cs/messages.json index f287debe06..71374ac94b 100644 --- a/_locales/cs/messages.json +++ b/_locales/cs/messages.json @@ -15,7 +15,6 @@ "actualSize": "Skutečná velikost", "add": "Přidat", "addAdmin": "{count, plural, one [Přidat správce] few [Přidat správce] many [Přidat správce] other [Přidat správce]}", - "addAdmins": "Přidat správce", "addAdminsDescription": "Zadejte ID účtu uživatele, kterého povyšujete na správce.

Chcete-li přidat více uživatelů, zadejte každé ID účtu oddělené čárkou. Najednou lze zadat až 20 ID účtů.", "adminCannotBeDemoted": "Správci nemohou být poníženi ani odebráni ze skupiny.", "adminCannotBeRemoved": "Správce nelze odebrat.", @@ -466,6 +465,7 @@ "failedResendInvite": "Nepodařilo se znovu odeslat pozvánku pro {name} ve skupině {group_name}", "failedResendInviteMultiple": "Nepodařilo se znovu odeslat pozvánku pro {name} a {count} dalších ve skupině {group_name}", "failedResendInviteTwo": "Nepodařilo se znovu odeslat pozvánku pro {name} a {other_name} ve skupině {group_name}", + "failedResendPromotionTwo": "Nepodařilo se znovu odeslat pozvánku pro {name} a {other_name} ve skupině {group_name}", "failedToDownload": "Stahování selhalo", "failures": "Chyby", "feedback": "Zpětná vazba", diff --git a/_locales/da/messages.json b/_locales/da/messages.json index caa60b645f..3320583fd4 100644 --- a/_locales/da/messages.json +++ b/_locales/da/messages.json @@ -13,7 +13,6 @@ "accountIdYoursDescription": "Dette er din kontoid. Andre brugere kan scanne den for at starte en samtale med dig.", "actualSize": "Faktisk størrelse", "add": "Tilføj", - "addAdmins": "Tilføj administratorer", "adminCannotBeRemoved": "Admins kan ikke fjernes.", "adminMorePromotedToAdmin": "{name} og {count} andre blev forfremmet til Admin.", "adminPromote": "Forfrem administratorer", diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 8c7331086c..12f07d927b 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Dies ist deine Account-ID. Andere Benutzer können sie scannen, um eine Unterhaltung mit dir zu beginnen.", "actualSize": "Originalgröße", "add": "Hinzufügen", - "addAdmins": "Administratoren hinzufügen", "addAdminsDescription": "Gib die Account-ID des Nutzers ein, den du zum Administrator ernennst.

Um mehrere Nutzer hinzuzufügen, gib jede Account-ID durch ein Komma getrennt ein. Es können bis zu 20 Account-IDs gleichzeitig angegeben werden.", "adminCannotBeRemoved": "Admins können nicht entfernt werden.", "adminMorePromotedToAdmin": "{name} und {count} andere wurden zu Admin befördert.", diff --git a/_locales/en/messages.json b/_locales/en/messages.json index db3d4c1af6..afed302015 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -15,7 +15,6 @@ "actualSize": "Actual Size", "add": "Add", "addAdmin": "{count, plural, one [Add Admin] other [Add Admins]}", - "addAdmins": "Add Admins", "addAdminsDescription": "Enter the Account ID of the user you are promoting to admin.

To add multiple users, enter each Account ID separated by a comma. Up to 20 Account IDs can be specified at a time.", "adminCannotBeDemoted": "Admins cannot be demoted or removed from the group.", "adminCannotBeRemoved": "Admins cannot be removed.", @@ -416,6 +415,8 @@ "displayNameVisible": "Your Display Name is visible to users, groups, and communities you interact with.", "document": "Document", "donate": "Donate", + "donateSessionDescription": "Powerful forces are trying to weaken privacy, but we can’t continue this fight alone.

Donating helps keep Session secure, independent, and online.", + "donateSessionHelp": "Session Needs Your Help", "done": "Done", "download": "Download", "downloading": "Downloading...", @@ -466,6 +467,9 @@ "failedResendInvite": "Failed to resend invite to {name} in {group_name}", "failedResendInviteMultiple": "Failed to resend invite to {name} and {count} others in {group_name}", "failedResendInviteTwo": "Failed to resend invite to {name} and {other_name} in {group_name}", + "failedResendPromotion": "Failed to resend promotion to {name} in {group_name}", + "failedResendPromotionMultiple": "Failed to resend promotion to {name} and {count} others in {group_name}", + "failedResendPromotionTwo": "Failed to resend promotion to {name} and {other_name} in {group_name}", "failedToDownload": "Failed to download", "failures": "Failures", "feedback": "Feedback", @@ -595,6 +599,7 @@ "inviteFailedDescription": "{count, plural, one [The invite could not be sent. Would you like to try again?] other [The invites could not be sent. Would you like to try again?]}", "inviteMembers": "{count, plural, one [Invite Member] other [Invite Members]}", "inviteNewMemberGroupLink": "Invite a new member to the group by entering your friend's Account ID, ONS or scanning their QR code {icon}", + "inviteNewMemberGroupNoLink": "Invite a new member to the group by entering your friend's Account ID, ONS or scanning their QR code", "join": "Join", "later": "Later", "launchOnStartDescriptionDesktop": "Launch Session automatically when your computer starts up.", @@ -642,6 +647,7 @@ "manageMembers": "Manage Members", "managePro": "Manage Pro", "max": "Max", + "maybeLater": "Maybe Later", "media": "Media", "memberSelected": "{count, plural, one [{count} Member Selected] other [{count} Members Selected]}", "members": "{count, plural, one [{count} member] other [{count} members]}", @@ -913,6 +919,7 @@ "proAccessSignUp": "Because you originally signed up for Session Pro via the {platform_store}, you'll need to use your {platform_account} to update your Pro access.", "proAccessUpgradeDesktop": "Currently, Pro access can only be purchased via the {platform_store} or {platform_store_other}. Because you are using Session Desktop, you're not able to upgrade to Pro here.

Session developers are working hard on alternative payment options to allow users to purchase Pro access outside of the {platform_store} and {platform_store_other}. Pro Roadmap {icon}", "proActivated": "Activated", + "proActivatingActivation": "activating", "proAllSet": "You're all set!", "proAllSetDescription": "Your Session Pro access was updated! You will be billed when Pro is automatically renewed on {date}.", "proAlreadyPurchased": "You’ve already got", @@ -985,6 +992,7 @@ "proPriceOneMonth": "1 Month - {monthly_price} / Month", "proPriceThreeMonths": "3 Months - {monthly_price} / Month", "proPriceTwelveMonths": "12 Months - {monthly_price} / Month", + "proReactivatingActivation": "re-activating", "proRefundAccountDevice": "Open this Session account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, request a refund via the Session Pro settings.", "proRefundDescription": "We’re sorry to see you go. Here's what you need to know before requesting a refund.", "proRefundNextSteps": "{platform} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your Pro status change in Session.", @@ -1002,6 +1010,7 @@ "proRenewPinMoreConversations": "Want to pin more conversations again?
Renew your Pro access to unlock the features you’ve been missing out on.", "proRenewTosPrivacy": "By renewing, you agree to the Session Pro Terms of Service {icon} and Privacy Policy {icon}", "proRenewalUnsuccessful": "Pro renewal unsuccessful, retrying soon", + "proRenewingAction": "renewing", "proRenewingNoAccessBilling": "Currently, Pro access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed Session using the {build_variant}, you're not able to renew here.

Session developers are working hard on alternative payment options to allow users to purchase Pro access outside of the {platform_store} and {platform_store_other}. Pro Roadmap {icon}", "proRequestedRefund": "Refund Requested", "proSendMore": "Send more with", @@ -1027,12 +1036,14 @@ "proUnlimitedPinsDescription": "Organize all your chats with unlimited pinned conversations.", "proUpdateAccessDescription": "Your current billing option grants {current_plan_length} of Pro access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?

By updating, your Pro access will automatically renew on {date} for an additional {selected_plan_length} of Pro access.", "proUpdateAccessExpireDescription": "Your Pro access will expire on {date}.

By updating, your Pro access will automatically renew on {date} for an additional {selected_plan_length} of Pro access.", + "proUpdatingAction": "updating", "proUpgradeAccess": "Upgrade to Session Pro Beta to get access to loads of exclusive perks and features.", "proUpgradeDesktopLinked": "Upgrade to Pro from the Session Pro settings on a linked device with Session installed via the {platform_store} or {platform_store_other}.", "proUpgradeNoAccessBilling": "Currently, Pro access can only be purchased via the {platform_store} or {platform_store_other}. Because you installed Session using the {build_variant}, you're not able to upgrade to Pro here.

Session developers are working hard on alternative payment options to allow users to purchase Pro access outside of the {platform_store} and {platform_store_other}. Pro Roadmap {icon}", "proUpgradeOption": "For now, there is only one way to upgrade:", "proUpgradeOptionsTwo": "For now, there are two ways to upgrade:", "proUpgraded": "You have upgraded to Session Pro!
Thank you for supporting the Session Network.", + "proUpgradingAction": "upgrading", "proUpgradingTo": "Upgrading to Pro", "proUpgradingTosPrivacy": "By upgrading, you agree to the Session Pro Terms of Service {icon} and Privacy Policy {icon}", "proUserProfileModalCallToAction": "Want to get more out of Session?
Upgrade to Session Pro Beta for a more powerful messaging experience.", @@ -1095,7 +1106,7 @@ "remindMeLater": "Remind Me Later", "remove": "Remove", "removeMember": "{count, plural, one [Remove Member] other [Remove Members]}", - "removeMemberMessages": "{count, plural, one [Remove member and their messages] other [Remove member and their messages]}", + "removeMemberMessages": "{count, plural, one [Remove member and their messages] other [Remove members and their messages]}", "removePasswordFail": "Failed to remove password", "removePasswordModalDescription": "Remove your current password for Session. Locally stored data will be re-encrypted with a randomly generated key, stored on your device.", "removingMember": "{count, plural, one [Removing member] other [Removing members]}", @@ -1142,6 +1153,7 @@ "sending": "Sending", "sendingCallOffer": "Sending Call Offer", "sendingConnectionCandidates": "Sending Connection Candidates", + "sendingPromotion": "{count, plural, one [Sending Promotion] other [Sending Promotions]}", "sent": "Sent:", "sessionAppearance": "Appearance", "sessionClearData": "Clear Data", @@ -1180,6 +1192,7 @@ "shareAccountIdDescriptionCopied": "Share with your friends wherever you usually speak with them — then move the conversation here.", "shareExtensionDatabaseError": "There is an issue opening the database. Please restart the app and try again.", "shareExtensionNoAccountError": "Oops! Looks like you don't have a Session account yet.

You'll need to create one in the Session app before you can share.", + "shareGroupMessageHistory": "Would you like to share group message history with this user?", "shareToSession": "Share to Session", "show": "Show", "showAll": "Show All", diff --git a/_locales/eo/messages.json b/_locales/eo/messages.json index d92eecefd0..70d18d3ddc 100644 --- a/_locales/eo/messages.json +++ b/_locales/eo/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Ĉi tio estas via Konto IDENT. Aliaj uzantoj povas skani ĝin por komenci konversacion kun vi.", "actualSize": "Efektiva grandeco", "add": "Aldoni", - "addAdmins": "Aldoni administrantojn", "adminCannotBeRemoved": "Administrantoj ne povas esti forigitaj.", "adminMorePromotedToAdmin": "{name} kaj {count} aliaj estis promociitaj al Admin.", "adminPromote": "Promocii administrantojn", diff --git a/_locales/es-419/messages.json b/_locales/es-419/messages.json index 779af512db..4fa30d43fd 100644 --- a/_locales/es-419/messages.json +++ b/_locales/es-419/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Este es tu Account ID. Otros usuarios pueden escanearlo para iniciar una conversación contigo.", "actualSize": "Tamaño original", "add": "Añadir", - "addAdmins": "Añadir Administradores", "addAdminsDescription": "Ingrese el Account ID del usuario que desea promover a administrador.

Para agregar varios usuarios, ingrese cada Account ID separado por una coma. Puede especificar hasta 20 Account ID a la vez.", "adminCannotBeRemoved": "Los administradores no pueden ser eliminados.", "adminMorePromotedToAdmin": "{name} y {count} más fueron promovidos a Admin.", diff --git a/_locales/es/messages.json b/_locales/es/messages.json index b68c0b6e06..a9189e3ab3 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Este es tu ID de cuenta. Otros usuarios pueden escanearlo para iniciar una conversación contigo.", "actualSize": "Tamaño original", "add": "Añadir", - "addAdmins": "Añadir Administradores", "addAdminsDescription": "Ingrese el Account ID del usuario que desea promover a administrador.

Para agregar varios usuarios, ingrese cada Account ID separado por una coma. Puede especificar hasta 20 Account ID a la vez.", "adminCannotBeRemoved": "Los administradores no pueden ser eliminados.", "adminMorePromotedToAdmin": "{name} y {count} más fueron promovidos a Administradores.", diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 37b13f0739..7b7ea03fed 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -15,7 +15,6 @@ "actualSize": "Taille réelle", "add": "Ajouter", "addAdmin": "{count, plural, one [Ajouter un administrateur] other [Ajouter des administrateurs]}", - "addAdmins": "Ajouter des administrateurs", "addAdminsDescription": "Entrez l'identifiant du compte de l'utilisateur que vous souhaitez promouvoir en administrateur.

Pour ajouter plusieurs utilisateurs, saisissez chaque identifiant de compte séparé par une virgule. Vous pouvez spécifier jusqu'à 20 identifiants à la fois.", "adminCannotBeDemoted": "Les admins ne peuvent pas être rétrogradés ou retirés du groupe.", "adminCannotBeRemoved": "Les administrateurs ne peuvent pas être supprimés.", @@ -466,6 +465,7 @@ "failedResendInvite": "Échec du renvoi de l'invitation à {name} dans {group_name}", "failedResendInviteMultiple": "Échec du renvoi de {name} et de {count} autres dans {group_name}", "failedResendInviteTwo": "Échec du renvoi de l'invitation à {name} et {other_name} dans {group_name}", + "failedResendPromotionTwo": "Échec du renvoi de l'invitation à {name} et {other_name} dans {group_name}", "failedToDownload": "Échec du téléchargement", "failures": "Échecs", "feedback": "Donner votre avis", diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index ca6604a596..bc8615e078 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "यह आपका Account ID है। अन्य उपयोगकर्ता आपके साथ बातचीत शुरू करने के लिए इसे स्कैन कर सकते हैं।", "actualSize": "वास्तविक आकार", "add": "जोड़ें", - "addAdmins": "एडमिन जोड़ें", "addAdminsDescription": "उस उपयोगकर्ता का Account ID दर्ज करें जिसे आप एडमिन बना रहे हैं।

एक से अधिक उपयोगकर्ताओं को जोड़ने के लिए, प्रत्येक Account ID को कॉमा से अलग करके दर्ज करें। एक बार में अधिकतम 20 Account ID दर्ज किए जा सकते हैं।", "adminCannotBeRemoved": "एडमिन हटाए नहीं जा सकते।", "adminMorePromotedToAdmin": "{name} और {count} अन्य को Admin बनाया गया।", diff --git a/_locales/hu/messages.json b/_locales/hu/messages.json index bb2cb3551f..193a2bc71f 100644 --- a/_locales/hu/messages.json +++ b/_locales/hu/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Ez a te Felhasználó ID-d. Más felhasználók beszkennelhetik, hogy egy beszélgetést indítsanak el veled.", "actualSize": "Eredeti méret", "add": "Hozzáadás", - "addAdmins": "Adminisztrátorok hozzáadása", "addAdminsDescription": "Adja meg a felhasználó fiókazonosítóját, akit adminisztrátorrá kíván kinevezni.

Egyszerre több felhasználó hozzáadásához adja meg az egyes fiókazonosítókat vesszővel elválasztva. Egyszerre legfeljebb 20 fiókazonosító adható meg.", "adminCannotBeRemoved": "Adminokat nem lehet eltávolítani.", "adminMorePromotedToAdmin": "{name} és {count} másik személy adminisztrátorrá lettek előléptetve.", diff --git a/_locales/id/messages.json b/_locales/id/messages.json index fe74f2f39b..f87cb4c9ac 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Ini adalah ID akun anda. Pengguna lain bisa memindainya untuk memulai percakapan dengan anda.", "actualSize": "Ukuran Sebenarnya", "add": "Tambahkan", - "addAdmins": "Tambah Admin", "adminCannotBeRemoved": "Admin tidak dapat dihapus.", "adminMorePromotedToAdmin": "{name} dan {count} lainnya dipromosikan menjadi Admin.", "adminPromote": "Promosikan Admin", diff --git a/_locales/it/messages.json b/_locales/it/messages.json index a2060156a4..0218164c0d 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Questo è il tuo ID utente. Altri utenti possono scansionarlo per iniziare una conversazione con te.", "actualSize": "Dimensione attuale", "add": "Aggiungi", - "addAdmins": "Aggiungi amministratori", "addAdminsDescription": "Inserisci l'Account ID dell'utente che vuoi promuovere ad amministratore.

Per aggiungere più utenti, inserisci ogni Account ID separato da una virgola. È possibile specificare fino a 20 Account ID alla volta.", "adminCannotBeRemoved": "Gli amministratori non possono essere rimossi.", "adminMorePromotedToAdmin": "{name} e altri {count} sono ora amministratori.", diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 5a6d56e163..15791f7c3a 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "これはあなたのアカウントIDです。他のユーザーはこれをスキャンしてあなたと会話を始めることができます。", "actualSize": "実サイズ", "add": "追加", - "addAdmins": "管理者を追加する", "addAdminsDescription": "管理者に昇格させるユーザーのAccount IDを入力してください。

複数のユーザーを追加するには、各Account IDをカンマで区切って入力してください。一度に最大20件まで指定できます。", "adminCannotBeRemoved": "アドミンを削除することはできません", "adminMorePromotedToAdmin": "{name}{count}人 がアドミンに昇格しました", diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 9065466083..d1828e920b 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "이는 당신의 계정 ID입니다. 다른 사용자가 이를 스캔하여 당신과 대화를 시작할 수 있습니다.", "actualSize": "실제 크기", "add": "추가", - "addAdmins": "관리자 추가", "addAdminsDescription": "관리자로 승격할 유저의 계정 ID를 입력하세요.

한번에 여러 유저를 추가하려면 각 계정 ID를 쉼표로 구분하여 최대 20명까지 추가할 수 있습니다.", "adminCannotBeRemoved": "관리자는 추방될 수 없습니다.", "adminMorePromotedToAdmin": "{name}님{count}명이 관리자(Admin)로 승격되었습니다.", diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index 3f34b75d3a..e29245e881 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Dit is uw Account-ID. Andere gebruikers kunnen het scannen om een gesprek met u te beginnen.", "actualSize": "Werkelijke grootte", "add": "Toevoegen", - "addAdmins": "Beheerders toevoegen", "addAdminsDescription": "Voer de account-ID in van de gebruiker die u promoot als admin.

Om meerdere gebruikers toe te voegen, voer elk account-ID in, gescheiden door een komma. Tot 20 Account ID's kunnen tegelijkertijd worden opgegeven.", "adminCannotBeRemoved": "Admins kunnen niet worden verwijderd.", "adminMorePromotedToAdmin": "{name} en {count} anderen zijn gepromoveerd tot Admin.", diff --git a/_locales/pl/messages.json b/_locales/pl/messages.json index 70dd22bd7b..22f994cf5b 100644 --- a/_locales/pl/messages.json +++ b/_locales/pl/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "To Twój identyfikator konta. Inni użytkownicy mogą go zeskanować, aby rozpocząć z Tobą rozmowę.", "actualSize": "Rzeczywisty rozmiar", "add": "Dodaj", - "addAdmins": "Dodaj administratorów", "addAdminsDescription": "Wprowadź identyfikator konta użytkownika, którego chcesz awansować na administratora.

Aby dodać wielu użytkowników, wpisz każdy identyfikator konta oddzielone przecinkiem. Można jednocześnie podać maksymalnie 20 identyfikatorów kont.", "adminCannotBeRemoved": "Nie można usuwać administratorów.", "adminMorePromotedToAdmin": "{name} i {count} innych zostali awansowani na administratów.", diff --git a/_locales/pt-PT/messages.json b/_locales/pt-PT/messages.json index aba49f7f4f..a92eb7937a 100644 --- a/_locales/pt-PT/messages.json +++ b/_locales/pt-PT/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Este é o seu ID da Conta. Outros utilizadores podem verificá-lo para iniciar uma conversa consigo.", "actualSize": "Tamanho Real", "add": "Adicionar", - "addAdmins": "Adicionar administradores", "addAdminsDescription": "Introduza o ID de Conta do utilizador que está a promover a administrador.

Para adicionar vários utilizadores, introduza cada ID de Conta separado por vírgulas. Podem ser especificados até 20 IDs de Conta de cada vez.", "adminCannotBeRemoved": "Admins não podem ser removidos.", "adminMorePromotedToAdmin": "{name} e {count} outros foram promovidos a administradores.", diff --git a/_locales/ro/messages.json b/_locales/ro/messages.json index 654fdae97f..938f4e3cd4 100644 --- a/_locales/ro/messages.json +++ b/_locales/ro/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Acesta este ID-ul tău de cont. Alți utilizatori îl pot scana pentru a începe o conversație cu tine.", "actualSize": "Mărime actuală", "add": "Adaugă", - "addAdmins": "Adaugă administratori", "addAdminsDescription": "Introdu ID-ul contului utilizatorului pe care îl promovezi ca administrator.

Pentru a adăuga mai mulți utilizatori, introduceți fiecare ID al contului separat prin virgulă. Pot fi specificate până la 20 de ID-uri de cont o dată.", "adminCannotBeRemoved": "Administratorii nu pot fi eliminați.", "adminMorePromotedToAdmin": "{name} și {count} alții au fost promovați la nivel de administrator.", diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index a9d131fa2a..283fbc3e5e 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Это ваш ID аккаунта. Другие пользователи могут сканировать его, чтобы начать беседу с вами.", "actualSize": "Фактический размер", "add": "Добавить", - "addAdmins": "Добавить администраторов", "addAdminsDescription": "Введите ID аккаунта пользователя, которого вы повышаете до администратора.

Чтобы добавить нескольких пользователей, введите ID каждого аккаунта через запятую. Одновременно можно указать до 20 идентификаторов учётных записей.", "adminCannotBeRemoved": "Администраторов нельзя удалить.", "adminMorePromotedToAdmin": "{name} и {count} других пользователей назначены администраторами.", diff --git a/_locales/sv/messages.json b/_locales/sv/messages.json index 94b6e198f5..f4a3d5dafa 100644 --- a/_locales/sv/messages.json +++ b/_locales/sv/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Detta är ditt konto-ID. Andra användare kan skanna det för att starta en konversation med dig.", "actualSize": "Aktuella storlek", "add": "Lägg till", - "addAdmins": "Lägg till administratörer", "addAdminsDescription": "Ange Account ID för användaren du gör till administratör.

För att lägga till flera användare, ange varje Account ID separerat med ett kommatecken. Upp till 20 Account ID:er kan anges åt gången.", "adminCannotBeRemoved": "Administratörer kan inte tas bort.", "adminMorePromotedToAdmin": "{name} och {count} andra blev befordrade till Admin.", diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index efdac87f70..46cf8f5be2 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Bu sizin Hesap Kimliğiniz. Diğer kullanıcılar, sizinle bir oturum başlatmak için tarayabilir.", "actualSize": "Normal Boyut", "add": "Ekle", - "addAdmins": "Yönetici Ekle", "addAdminsDescription": "Yönetici olarak atadığınız kullanıcının Hesap Kimliğini girin.

Birden fazla kullanıcı eklemek için her Hesap Kimliğini virgülle ayırarak girin. Tek seferde en fazla 20 Hesap Kimliği belirtilebilir.", "adminCannotBeRemoved": "Yöneticiler kaldırılamaz.", "adminMorePromotedToAdmin": "{name} ve {count} diğer yönetici olarak terfi etti.", diff --git a/_locales/uk/messages.json b/_locales/uk/messages.json index ebf0477bc3..d4a6e2ddd7 100644 --- a/_locales/uk/messages.json +++ b/_locales/uk/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "Це ваш Account ID. Інші користувачі можуть просканувати його, щоб почати розмову з вами.", "actualSize": "Актуальний розмір", "add": "Додати", - "addAdmins": "Додати адміністраторів", "addAdminsDescription": "Введіть ідентифікатор облікового запису користувача, якого ви призначаєте адміністратором.

Щоб додати кількох користувачів, введіть ідентифікатори їхніх облікових записів, розділяючи їх комами. Одночасно можна вказати до 20 ідентифікаторів облікових записів.", "adminCannotBeRemoved": "Адміністратори не можуть бути видалені.", "adminMorePromotedToAdmin": "{name} та ще {count} інших було підвищено до адміністраторів.", diff --git a/_locales/zh-CN/messages.json b/_locales/zh-CN/messages.json index 3e322d9695..77032eb0a9 100644 --- a/_locales/zh-CN/messages.json +++ b/_locales/zh-CN/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "这是您的账户ID。其他用户可以扫描它来与您开始会话。", "actualSize": "实际尺寸", "add": "添加", - "addAdmins": "添加管理员", "addAdminsDescription": "请输入您正在授权为管理员的用户的帐户 ID。

要添加多个用户,请输入用逗号分隔的每个帐户 ID。一次最多可以指定20个帐户 ID。", "adminCannotBeRemoved": "管理员无法被移除。", "adminMorePromotedToAdmin": "{name}和其他{count}名成员被设置为管理员。", diff --git a/_locales/zh-TW/messages.json b/_locales/zh-TW/messages.json index 9804bc6a05..cfb2b4a25e 100644 --- a/_locales/zh-TW/messages.json +++ b/_locales/zh-TW/messages.json @@ -14,7 +14,6 @@ "accountIdYoursDescription": "這是您的帳號 ID。其他使用者可以掃描來與你對話。", "actualSize": "實際大小", "add": "新增", - "addAdmins": "新增管理員", "addAdminsDescription": "請輸入您要晉升為管理員的使用者的 Account ID。

若要新增多位使用者,請輸入以逗號分隔的每個 Account ID。一次最多可指定 20 個 Account ID。", "adminCannotBeRemoved": "無法移除管理員。", "adminMorePromotedToAdmin": "{name}{count} 位其他成員 被設置為管理員。", diff --git a/images/cta/donate.webp b/images/cta/donate.webp new file mode 100644 index 0000000000..b7fbe5ae9e Binary files /dev/null and b/images/cta/donate.webp differ diff --git a/package.json b/package.json index 1bbef936ff..58208186ec 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "fs-extra": "11.3.0", "glob": "10.4.5", "image-type": "^4.1.0", - "libsession_util_nodejs": "https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.1/libsession_util_nodejs-v0.6.1.tar.gz", + "libsession_util_nodejs": "https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.2/libsession_util_nodejs-v0.6.2.tar.gz", "libsodium-wrappers-sumo": "^0.7.15", "linkify-it": "^5.0.0", "lodash": "^4.17.21", diff --git a/ts/components/basic/SessionButton.tsx b/ts/components/basic/SessionButton.tsx index fa11b38b89..40399efc6b 100644 --- a/ts/components/basic/SessionButton.tsx +++ b/ts/components/basic/SessionButton.tsx @@ -128,14 +128,11 @@ const StyledSolidButton = styled(StyledBaseButton)<{ isDarkTheme: boolean }>` &:hover { background-color: var(--transparent-color); color: ${props => - props.isDarkTheme && props.color && props.color !== SessionButtonColor.Tertiary - ? `var(--${props.color}-color)` - : `var(--button-solid-text-hover-color)`}; - border: 1px solid - ${props => - props.isDarkTheme && props.color + props.isDarkTheme + ? props.color && props.color !== SessionButtonColor.Tertiary ? `var(--${props.color}-color)` - : `var(--button-solid-text-hover-color)`}; + : 'var(--primary-color)' + : `var(--button-solid-text-hover-color)`}; } } `; diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index f508ef3149..6d335bfa72 100644 --- a/ts/components/conversation/composition/CompositionBox.tsx +++ b/ts/components/conversation/composition/CompositionBox.tsx @@ -61,10 +61,11 @@ import { useShowBlockUnblock } from '../../menuAndSettingsHooks/useShowBlockUnbl import { showLocalizedPopupDialog } from '../../dialog/LocalizedPopupDialog'; import { formatNumber } from '../../../util/i18n/formatting/generics'; import { getFeatureFlag } from '../../../state/ducks/types/releasedFeaturesReduxTypes'; -import { ProCTAVariant, showSessionProInfoDialog } from '../../dialog/SessionProInfoModal'; +import { showSessionCTA } from '../../dialog/SessionCTA'; import { tStripped } from '../../../localization/localeTools'; import type { ProcessedLinkPreviewThumbnailType } from '../../../webworker/workers/node/image_processor/image_processor'; import { selectWeAreProUser } from '../../../hooks/useParamSelector'; +import { CTAVariant } from '../../dialog/cta/types'; export interface ReplyingToMessageProps { convoId: string; @@ -750,7 +751,7 @@ class CompositionBoxInner extends Component { const dispatch = window.inboxStore?.dispatch; if (dispatch) { if (isProAvailable && !hasPro) { - showSessionProInfoDialog(ProCTAVariant.MESSAGE_CHARACTER_LIMIT, dispatch); + showSessionCTA(CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT, dispatch); } else { showLocalizedPopupDialog( { diff --git a/ts/components/dialog/CTADescriptionList.tsx b/ts/components/dialog/CTADescriptionList.tsx new file mode 100644 index 0000000000..f91b63c35c --- /dev/null +++ b/ts/components/dialog/CTADescriptionList.tsx @@ -0,0 +1,45 @@ +import { ReactNode } from 'react'; +import styled from 'styled-components'; +import { FileIcon } from '../icon/FileIcon'; +import { LucideIcon } from '../icon/LucideIcon'; +import { LUCIDE_ICONS_UNICODE } from '../icon/lucide'; + +export const StyledCTADescriptionList = styled.ul` + list-style: none; + padding-inline-start: 0; + text-align: start; + display: grid; + font-size: var(--font-size-lg); + grid-row-gap: var(--margins-md); + margin-block: 0; +`; + +const StyledListItem = styled.li` + display: inline-flex; + gap: var(--margins-sm); + align-items: end; + line-height: normal; +`; + +export function CTADescriptionListItem({ + children, + customIconSrc, +}: { + children: ReactNode; + customIconSrc?: string; +}) { + return ( + + {customIconSrc ? ( + + ) : ( + + )} + {children} + + ); +} diff --git a/ts/components/dialog/EditProfilePictureModal.tsx b/ts/components/dialog/EditProfilePictureModal.tsx index be853670ee..a9cdd75889 100644 --- a/ts/components/dialog/EditProfilePictureModal.tsx +++ b/ts/components/dialog/EditProfilePictureModal.tsx @@ -7,7 +7,7 @@ import { userSettingsModal, updateEditProfilePictureModal, updateConversationDetailsModal, - updateSessionProInfoModal, + updateSessionCTA, } from '../../state/ducks/modalDialog'; import type { EditProfilePictureModalProps } from '../../types/ReduxTypes'; import { pickFileForAvatar } from '../../types/attachments/VisualAttachment'; @@ -31,7 +31,6 @@ import { } from '../SessionWrapperModal'; import { useIsProAvailable } from '../../hooks/useIsProAvailable'; import { SpacerLG, SpacerSM } from '../basic/Text'; -import { ProCTAVariant } from './SessionProInfoModal'; import { AvatarSize } from '../avatar/Avatar'; import { ProIconButton } from '../buttons/ProButton'; import { useProBadgeOnClickCb } from '../menuAndSettingsHooks/useProBadgeOnClickCb'; @@ -45,6 +44,7 @@ import { useEditProfilePictureModal, useUpdateConversationDetailsModal, } from '../../state/selectors/modal'; +import { CTAVariant } from './cta/types'; const StyledAvatarContainer = styled.div` cursor: pointer; @@ -177,8 +177,8 @@ export const EditProfilePictureModal = ({ conversationId }: EditProfilePictureMo args: { cta: { variant: userHasPro - ? ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED - : ProCTAVariant.ANIMATED_DISPLAY_PICTURE, + ? CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED + : CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE, afterActionButtonCallback, actionButtonNextModalAfterCloseCallback, }, @@ -229,8 +229,8 @@ export const EditProfilePictureModal = ({ conversationId }: EditProfilePictureMo */ if (isProAvailable && !userHasPro && isNewAvatarAnimated && !isCommunity) { dispatch( - updateSessionProInfoModal({ - variant: ProCTAVariant.ANIMATED_DISPLAY_PICTURE, + updateSessionCTA({ + variant: CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE, afterActionButtonCallback, actionButtonNextModalAfterCloseCallback, }) diff --git a/ts/components/dialog/ModalContainer.tsx b/ts/components/dialog/ModalContainer.tsx index 2be41b7935..3f679f57fa 100644 --- a/ts/components/dialog/ModalContainer.tsx +++ b/ts/components/dialog/ModalContainer.tsx @@ -45,7 +45,7 @@ import { BlockOrUnblockDialog } from './blockOrUnblock/BlockOrUnblockDialog'; import { DebugMenuModal } from './debug/DebugMenuModal'; import { ConversationSettingsDialog } from './conversationSettings/conversationSettingsDialog'; import { SessionConfirm } from './SessionConfirm'; -import { SessionProInfoModal } from './SessionProInfoModal'; +import { SessionCTA } from './SessionCTA'; import { LocalizedPopupDialog } from './LocalizedPopupDialog'; import { UserSettingsDialog } from './user-settings/UserSettingsDialog'; @@ -113,7 +113,7 @@ export const ModalContainer = () => { {localizedPopupDialogState && } {lightBoxOptions && } {openUrlModalState && } - {sessionProInfoState && } + {sessionProInfoState && } {/* Should be on top of all other modals */} {confirmModalState && } diff --git a/ts/components/dialog/ModeratorsAddDialog.tsx b/ts/components/dialog/ModeratorsAddDialog.tsx index b5de749aa4..c378486eb4 100644 --- a/ts/components/dialog/ModeratorsAddDialog.tsx +++ b/ts/components/dialog/ModeratorsAddDialog.tsx @@ -9,7 +9,7 @@ import { ConvoHub } from '../../session/conversations'; import { updateAddModeratorsModal } from '../../state/ducks/modalDialog'; import { SessionButton, SessionButtonType } from '../basic/SessionButton'; import { SessionSpinner } from '../loading'; -import { tr } from '../../localization/localeTools'; +import { PLURAL_COUNT_OTHER, tr } from '../../localization/localeTools'; import { MAX_SUBREQUESTS_COUNT } from '../../session/apis/snode_api/SnodeRequestTypes'; import { ModalBasicHeader, @@ -20,6 +20,7 @@ import { ModalSimpleSessionInput } from '../inputs/SessionInput'; import { ClearInputButton } from '../inputs/ClearInputButton'; import { ModalDescription } from './shared/ModalDescriptionContainer'; import { ModalFlexContainer } from './shared/ModalFlexContainer'; +import { Localizer } from '../basic/Localizer'; type Props = { conversationId: string; @@ -87,7 +88,9 @@ export const AddModeratorsDialog = (props: Props) => { return ( } + headerChildren={ + } /> + } onClose={onClose} buttonChildren={ diff --git a/ts/components/dialog/OpenUrlModal.tsx b/ts/components/dialog/OpenUrlModal.tsx index 0667de3fff..a610f2b720 100644 --- a/ts/components/dialog/OpenUrlModal.tsx +++ b/ts/components/dialog/OpenUrlModal.tsx @@ -14,6 +14,7 @@ import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/S import { SpacerXS } from '../basic/Text'; import { ModalDescription } from './shared/ModalDescriptionContainer'; import { tr } from '../../localization/localeTools'; +import { registerUrlInteraction, URLInteraction } from '../../util/urlHistory'; const StyledScrollDescriptionContainer = styled.div` max-height: 150px; @@ -33,13 +34,15 @@ export function OpenUrlModal(props: OpenUrlModalState) { dispatch(updateOpenUrlModal(null)); } - function onClickOpen() { + async function onClickOpen() { void shell.openExternal(url); onClose(); + await registerUrlInteraction(url, URLInteraction.OPEN); } - function onClickCopy() { + async function onClickCopy() { MessageInteraction.copyBodyToClipboard(url); + await registerUrlInteraction(url, URLInteraction.COPY); onClose(); } diff --git a/ts/components/dialog/ProCTADescription.tsx b/ts/components/dialog/ProCTADescription.tsx new file mode 100644 index 0000000000..a4d3af2f06 --- /dev/null +++ b/ts/components/dialog/ProCTADescription.tsx @@ -0,0 +1,194 @@ +import { ReactNode, useMemo } from 'react'; +import { useCurrentUserHasExpiredPro, useProAccessDetails } from '../../hooks/useHasPro'; +import { Localizer } from '../basic/Localizer'; +import { CTADescriptionListItem, StyledCTADescriptionList } from './CTADescriptionList'; +import { StyledScrollDescriptionContainer } from './SessionCTA'; +import { tr } from '../../localization/localeTools'; +import { CONVERSATION } from '../../session/constants'; +import { formatNumber } from '../../util/i18n/formatting/generics'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; +import { ProIconButton } from '../buttons/ProButton'; +import { CTAVariant, type ProCTAVariant } from './cta/types'; + +const variantsWithoutFeatureList = [ + CTAVariant.PRO_GROUP_NON_ADMIN, + CTAVariant.PRO_GROUP_ACTIVATED, + CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED, +] as const; + +type VariantWithoutFeatureList = (typeof variantsWithoutFeatureList)[number]; +type VariantWithFeatureList = Exclude; + +function isProFeatureListCTA(variant: CTAVariant): variant is VariantWithFeatureList { + return !variantsWithoutFeatureList.includes(variant as any); +} + +enum ProFeatureKey { + LONGER_MESSAGES = 'proFeatureListLongerMessages', + MORE_PINNED_CONVOS = 'proFeatureListPinnedConversations', + ANIMATED_DP = 'proFeatureListAnimatedDisplayPicture', + LARGER_GROUPS = 'proFeatureListLargerGroups', +} + +function getBaseFeatureList(variant: VariantWithFeatureList) { + switch (variant) { + case CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT: + return [ProFeatureKey.LONGER_MESSAGES, ProFeatureKey.MORE_PINNED_CONVOS]; + + case CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE: + return [ProFeatureKey.ANIMATED_DP, ProFeatureKey.LONGER_MESSAGES]; + + case CTAVariant.PRO_PINNED_CONVERSATION_LIMIT: + case CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED: + return [ProFeatureKey.MORE_PINNED_CONVOS, ProFeatureKey.LONGER_MESSAGES]; + + case CTAVariant.PRO_GENERIC: // yes generic has the same as above, reversed... + return [ProFeatureKey.LONGER_MESSAGES, ProFeatureKey.MORE_PINNED_CONVOS]; + + case CTAVariant.PRO_EXPIRING_SOON: + case CTAVariant.PRO_EXPIRED: + return [ + ProFeatureKey.LONGER_MESSAGES, + ProFeatureKey.MORE_PINNED_CONVOS, + ProFeatureKey.ANIMATED_DP, + ]; + + case CTAVariant.PRO_GROUP_ADMIN: + return [ProFeatureKey.LARGER_GROUPS, ProFeatureKey.LONGER_MESSAGES]; + + default: + assertUnreachable(variant, 'getFeatureList unreachable case'); + throw new Error('unreachable'); + } +} + +function FeatureList({ variant }: { variant: CTAVariant }) { + const featureList = useMemo(() => { + if (!isProFeatureListCTA(variant)) { + return []; + } + const features = getBaseFeatureList(variant).map(token => ( + {tr(token)} + )); + + // Expiry related CTAs dont show the "more" feature item + if (variant !== CTAVariant.PRO_EXPIRED && variant !== CTAVariant.PRO_EXPIRING_SOON) { + features.push( + + {tr('proFeatureListLoadsMore')} + + ); + } + return features; + }, [variant]); + + return featureList.length ? ( + {featureList} + ) : null; +} + +function ProExpiringSoonDescription() { + const { data } = useProAccessDetails(); + return ; +} + +function getDescription(variant: ProCTAVariant, userHasProExpired: boolean): ReactNode { + switch (variant) { + case CTAVariant.PRO_PINNED_CONVERSATION_LIMIT: + return ( + + ); + + case CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED: + return ( + + ); + + case CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE: + return ( + + ); + + case CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED: + return ( + <> + + {' '} + + +
+ + + ); + + case CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT: + return ( + + ); + + case CTAVariant.PRO_GENERIC: + return ( + + ); + + case CTAVariant.PRO_EXPIRING_SOON: + return ; + + case CTAVariant.PRO_EXPIRED: + return ; + + // TODO: Group CTA string dont all exist yet and need to be implemented later + case CTAVariant.PRO_GROUP_ADMIN: + case CTAVariant.PRO_GROUP_NON_ADMIN: + case CTAVariant.PRO_GROUP_ACTIVATED: + return ( + + {' '} + + + ); + + default: + assertUnreachable(variant, 'getDescription unreachable case'); + throw new Error('unreachable'); + } +} + +export function ProCTADescription({ variant }: { variant: ProCTAVariant }) { + const userHasExpiredPro = useCurrentUserHasExpiredPro(); + return ( + <> + + {getDescription(variant, userHasExpiredPro)} + + + + ); +} diff --git a/ts/components/dialog/ProCTATitle.tsx b/ts/components/dialog/ProCTATitle.tsx new file mode 100644 index 0000000000..2386a76fb8 --- /dev/null +++ b/ts/components/dialog/ProCTATitle.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; +import { useCurrentUserHasExpiredPro } from '../../hooks/useHasPro'; +import { StyledCTATitle } from './SessionCTA'; +import { Localizer } from '../basic/Localizer'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; +import { ProIconButton } from '../buttons/ProButton'; +import { CTAVariant, isProCTAFeatureVariant, type ProCTAVariant } from './cta/types'; + +export function ProCTATitle({ variant }: { variant: ProCTAVariant }) { + const userHasExpiredPro = useCurrentUserHasExpiredPro(); + + const titleText = useMemo(() => { + if (isProCTAFeatureVariant(variant)) { + return ; + } + + switch (variant) { + // TODO: Group CTA titles arent finalised and need to be implemneted later + case CTAVariant.PRO_GROUP_NON_ADMIN: + return ; + + case CTAVariant.PRO_GROUP_ADMIN: + return ; + + case CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED: + return ; + + case CTAVariant.PRO_GROUP_ACTIVATED: + return ; + + case CTAVariant.PRO_EXPIRING_SOON: + return ; + + case CTAVariant.PRO_EXPIRED: + return ; + + default: + assertUnreachable(variant, 'CtaTitle'); + throw new Error('unreachable'); + } + }, [variant, userHasExpiredPro]); + + const isTitleDirectionReversed = useMemo(() => { + return [ + CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED, + CTAVariant.PRO_GROUP_ACTIVATED, + CTAVariant.PRO_EXPIRING_SOON, + CTAVariant.PRO_EXPIRED, + ].includes(variant); + }, [variant]); + + return ( + + {titleText} + + + ); +} diff --git a/ts/components/dialog/SessionCTA.tsx b/ts/components/dialog/SessionCTA.tsx new file mode 100644 index 0000000000..34f428ef67 --- /dev/null +++ b/ts/components/dialog/SessionCTA.tsx @@ -0,0 +1,455 @@ +import { isNil } from 'lodash'; +import { Dispatch, useMemo, type ReactNode } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; +import type { CSSProperties } from 'styled-components'; +import { + type SessionCTAState, + updateSessionCTA, + userSettingsModal, + UserSettingsModalState, +} from '../../state/ducks/modalDialog'; +import { + SessionWrapperModal, + WrapperModalWidth, + ModalActionsContainer, +} from '../SessionWrapperModal'; +import { + SessionButton, + SessionButtonColor, + type SessionButtonProps, + SessionButtonShape, + SessionButtonType, +} from '../basic/SessionButton'; +import { SpacerSM, SpacerXL } from '../basic/Text'; +import type { MergedLocalizerTokens } from '../../localization/localeTools'; +import { SessionButtonShiny } from '../basic/SessionButtonShiny'; +import { useIsProAvailable } from '../../hooks/useIsProAvailable'; +import { useCurrentUserHasPro } from '../../hooks/useHasPro'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; +import { Storage } from '../../util/storage'; +import { SettingsKey } from '../../data/settings-key'; +import { ProCTADescription } from './ProCTADescription'; +import { ProCTATitle } from './ProCTATitle'; +import { + CTAVariant, + type CTAVariantExcludingProCTAs, + isProCTAVariant, + type ProCTAVariant, + isProCTAFeatureVariant, +} from './cta/types'; +import { getFeatureFlag } from '../../state/ducks/types/releasedFeaturesReduxTypes'; +import { showLinkVisitWarningDialog } from './OpenUrlModal'; +import { APP_URL, DURATION } from '../../session/constants'; +import { Data } from '../../data/data'; +import { getUrlInteractionsForUrl, URLInteraction } from '../../util/urlHistory'; +import { Localizer } from '../basic/Localizer'; + +function useIsProCTAVariant(v: CTAVariant): v is ProCTAVariant { + return useMemo(() => isProCTAVariant(v), [v]); +} + +const StyledContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: var(--margins-xl); +`; + +export const StyledScrollDescriptionContainer = styled.div` + text-align: center; + font-size: var(--font-size-lg); + line-height: var(--font-size-xl); + color: var(--text-secondary-color); +`; + +const StyledCTAImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + object-position: right center; + background-color: var(--primary-color); + + mask-image: linear-gradient(to bottom, black 66%, transparent 97%); + mask-size: 100% 100%; +`; + +const StyledAnimationImage = styled.img` + position: absolute; +`; + +const StyledAnimatedCTAImageContainer = styled.div<{ noColor?: boolean }>` + position: relative; + ${props => (props.noColor ? 'filter: grayscale(100%) brightness(0.8);' : '')} +`; + +function AnimatedCTAImage({ + ctaLayerSrc, + animatedLayerSrc, + animationStyle, + noColor, +}: { + ctaLayerSrc: string; + animatedLayerSrc: string; + animationStyle: CSSProperties; + noColor?: boolean; +}) { + return ( + + + + + ); +} + +export const StyledCTATitle = styled.span<{ reverseDirection?: boolean }>` + font-size: var(--font-size-h4); + font-weight: bold; + line-height: normal; + display: inline-flex; + flex-direction: ${props => (props.reverseDirection ? 'row-reverse' : 'row')}; + align-items: center; + gap: var(--margins-xs); + padding: 3px; +`; + +function isVariantWithActionButton(variant: CTAVariant): boolean { + return ![ + CTAVariant.PRO_GROUP_NON_ADMIN, + CTAVariant.PRO_GROUP_ACTIVATED, + CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED, + ].includes(variant); +} + +function getImage(variant: CTAVariant): ReactNode { + switch (variant) { + case CTAVariant.PRO_PINNED_CONVERSATION_LIMIT: + case CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED: + return ; + + case CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE: + case CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED: + return ( + + ); + + case CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT: + return ; + + // TODO: Group CTA images dont exist yet and need to be implemented later + case CTAVariant.PRO_GROUP_ADMIN: + case CTAVariant.PRO_GROUP_NON_ADMIN: + case CTAVariant.PRO_GROUP_ACTIVATED: + return ; + + case CTAVariant.PRO_GENERIC: + case CTAVariant.PRO_EXPIRING_SOON: + case CTAVariant.PRO_EXPIRED: + return ( + + ); + + case CTAVariant.DONATE_GENERIC: + return ; + + default: + assertUnreachable(variant, 'getImage'); + throw new Error('unreachable'); + } +} + +function getTitle(variant: CTAVariantExcludingProCTAs) { + switch (variant) { + case CTAVariant.DONATE_GENERIC: + return ; + default: + assertUnreachable(variant, 'CtaTitle'); + throw new Error('unreachable'); + } +} + +function CtaTitle({ variant }: { variant: CTAVariant }) { + const isProCTA = useIsProCTAVariant(variant); + if (isProCTA) { + return ; + } + + return {getTitle(variant)}; +} + +function getDescription(variant: CTAVariantExcludingProCTAs) { + switch (variant) { + case CTAVariant.DONATE_GENERIC: + return ; + + default: + assertUnreachable(variant, 'CtaTitle'); + throw new Error('unreachable'); + } +} + +function CTADescription({ variant }: { variant: CTAVariant }) { + const isProCTA = useIsProCTAVariant(variant); + if (isProCTA) { + return ; + } + + return ( + {getDescription(variant)} + ); +} + +// TODO: we might want to make this a specific button preset. As its used for all pro/sesh stuff +export const proButtonProps = { + buttonShape: SessionButtonShape.Square, + buttonType: SessionButtonType.Solid, + fontWeight: 400, + style: { + height: '46px', + width: '100%', + }, +} satisfies SessionButtonProps; + +function Buttons({ + variant, + onClose, + afterActionButtonCallback, + actionButtonNextModalAfterCloseCallback, +}: { + variant: CTAVariant; + onClose: () => void; + afterActionButtonCallback?: () => void; + actionButtonNextModalAfterCloseCallback?: () => void; +}) { + const dispatch = useDispatch(); + + const actionButton = useMemo(() => { + if (!isVariantWithActionButton(variant)) { + return null; + } + + if (variant === CTAVariant.DONATE_GENERIC) { + return ( + { + showLinkVisitWarningDialog(APP_URL.DONATE, dispatch); + onClose(); + }} + dataTestId="modal-session-pro-confirm-button" + > + + + ); + } + + let settingsModalProps: UserSettingsModalState = { + userSettingsPage: 'pro', + hideBackButton: true, + fromCTA: true, + centerAlign: true, + afterCloseAction: actionButtonNextModalAfterCloseCallback, + }; + + let buttonTextKey: MergedLocalizerTokens = 'theContinue'; + + if (variant === CTAVariant.PRO_EXPIRED || variant === CTAVariant.PRO_EXPIRING_SOON) { + settingsModalProps = { + userSettingsPage: 'proNonOriginating', + nonOriginatingVariant: variant === CTAVariant.PRO_EXPIRED ? 'renew' : 'update', + hideBackButton: true, + centerAlign: true, + afterCloseAction: actionButtonNextModalAfterCloseCallback, + }; + + buttonTextKey = variant === CTAVariant.PRO_EXPIRED ? 'renew' : 'update'; + } + + return ( + { + onClose(); + dispatch(userSettingsModal(settingsModalProps)); + afterActionButtonCallback?.(); + }} + dataTestId="modal-session-pro-confirm-button" + > + + + ); + }, [ + variant, + dispatch, + onClose, + actionButtonNextModalAfterCloseCallback, + afterActionButtonCallback, + ]); + + const closeButtonToken: MergedLocalizerTokens = useMemo(() => { + if (variant === CTAVariant.DONATE_GENERIC) { + return 'maybeLater'; + } + return actionButton && variant !== CTAVariant.PRO_EXPIRING_SOON ? 'cancel' : 'close'; + }, [variant, actionButton]); + + return ( + + {actionButton} + + + + + ); +} + +export function SessionCTA(props: SessionCTAState) { + const dispatch = useDispatch(); + const hasPro = useCurrentUserHasPro(); + + function onClose() { + dispatch(updateSessionCTA(null)); + } + + const variant = props?.variant; + + // NOTE: Feature CTAs shouldnt show for users with pro + if (isNil(variant) || (hasPro && isProCTAFeatureVariant(variant))) { + return null; + } + + return ( + + } + > + + + + + + + + + ); +} + +export const showSessionCTA = (variant: CTAVariant, dispatch: Dispatch) => { + dispatch( + updateSessionCTA({ + variant, + }) + ); +}; + +export const useShowSessionCTACb = (variant: CTAVariant) => { + const dispatch = useDispatch(); + + // TODO: remove once pro is released + const isProAvailable = useIsProAvailable(); + const isProCTA = useIsProCTAVariant(variant); + if (isProCTA && !isProAvailable) { + return () => null; + } + + return () => showSessionCTA(variant, dispatch); +}; + +export const useShowSessionCTACbWithVariant = () => { + const dispatch = useDispatch(); + + // TODO: remove once pro is released + const isProAvailable = useIsProAvailable(); + + return (variant: CTAVariant) => { + if (isProCTAVariant(variant) && !isProAvailable) { + return; + } + showSessionCTA(variant, dispatch); + }; +}; + +export async function handleTriggeredProCTAs(dispatch: Dispatch) { + const proAvailable = getFeatureFlag('proAvailable'); + + if (Storage.get(SettingsKey.proExpiringSoonCTA)) { + if (!proAvailable) { + return; + } + dispatch( + updateSessionCTA({ + variant: CTAVariant.PRO_EXPIRING_SOON, + }) + ); + await Storage.put(SettingsKey.proExpiringSoonCTA, false); + } else if (Storage.get(SettingsKey.proExpiredCTA)) { + if (!proAvailable) { + return; + } + dispatch( + updateSessionCTA({ + variant: CTAVariant.PRO_EXPIRED, + }) + ); + await Storage.put(SettingsKey.proExpiredCTA, false); + } else { + const dbCreationTimestampMs = await Data.getDBCreationTimestampMs(); + if (dbCreationTimestampMs && dbCreationTimestampMs + 7 * DURATION.DAYS < Date.now()) { + const donateInteractions = getUrlInteractionsForUrl(APP_URL.DONATE); + if ( + !donateInteractions.includes(URLInteraction.COPY) && + !donateInteractions.includes(URLInteraction.OPEN) + ) { + dispatch(updateSessionCTA({ variant: CTAVariant.DONATE_GENERIC })); + } + } + } +} diff --git a/ts/components/dialog/SessionProInfoModal.tsx b/ts/components/dialog/SessionProInfoModal.tsx deleted file mode 100644 index 4e2adbdb94..0000000000 --- a/ts/components/dialog/SessionProInfoModal.tsx +++ /dev/null @@ -1,655 +0,0 @@ -import { isNil } from 'lodash'; -import { Dispatch, useMemo, type ReactNode } from 'react'; -import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; -import type { CSSProperties } from 'styled-components'; -import { - type SessionProInfoState, - updateSessionProInfoModal, - userSettingsModal, - UserSettingsModalState, -} from '../../state/ducks/modalDialog'; -import { - SessionWrapperModal, - WrapperModalWidth, - ModalActionsContainer, -} from '../SessionWrapperModal'; -import { - SessionButton, - SessionButtonColor, - type SessionButtonProps, - SessionButtonShape, - SessionButtonType, -} from '../basic/SessionButton'; -import { SpacerSM, SpacerXL } from '../basic/Text'; -import { LucideIcon } from '../icon/LucideIcon'; -import { LUCIDE_ICONS_UNICODE } from '../icon/lucide'; -import { MergedLocalizerTokens, tr } from '../../localization/localeTools'; -import { FileIcon } from '../icon/FileIcon'; -import { SessionButtonShiny } from '../basic/SessionButtonShiny'; -import { useIsProAvailable } from '../../hooks/useIsProAvailable'; -import { - useCurrentUserHasExpiredPro, - useCurrentUserHasPro, - useProAccessDetails, -} from '../../hooks/useHasPro'; -import { ProIconButton } from '../buttons/ProButton'; -import { assertUnreachable } from '../../types/sqlSharedTypes'; -import { Localizer } from '../basic/Localizer'; -import { CONVERSATION } from '../../session/constants'; -import { formatNumber } from '../../util/i18n/formatting/generics'; -import { Storage } from '../../util/storage'; -import { SettingsKey } from '../../data/settings-key'; - -export enum ProCTAVariant { - GENERIC = 0, - // Feature - has expired sub variants - MESSAGE_CHARACTER_LIMIT = 1, - ANIMATED_DISPLAY_PICTURE = 2, - ANIMATED_DISPLAY_PICTURE_ACTIVATED = 3, - PINNED_CONVERSATION_LIMIT = 4, - PINNED_CONVERSATION_LIMIT_GRANDFATHERED = 5, - // Groups - GROUP_NON_ADMIN = 6, - GROUP_ADMIN = 7, - GROUP_ACTIVATED = 8, - // Special - EXPIRING_SOON = 9, - EXPIRED = 10, -} - -const StyledContentContainer = styled.div` - display: flex; - flex-direction: column; - gap: var(--margins-xl); -`; - -const StyledScrollDescriptionContainer = styled.div` - text-align: center; - font-size: var(--font-size-lg); - line-height: var(--font-size-xl); - color: var(--text-secondary-color); -`; - -const StyledCTAImage = styled.img` - width: 100%; - height: 100%; - object-fit: cover; - object-position: right center; - background-color: var(--primary-color); - - mask-image: linear-gradient(to bottom, black 66%, transparent 97%); - mask-size: 100% 100%; -`; - -const StyledAnimationImage = styled.img` - position: absolute; -`; - -const StyledAnimatedCTAImageContainer = styled.div<{ noColor?: boolean }>` - position: relative; - ${props => (props.noColor ? 'filter: grayscale(100%) brightness(0.8);' : '')} -`; - -function AnimatedCTAImage({ - ctaLayerSrc, - animatedLayerSrc, - animationStyle, - noColor, -}: { - ctaLayerSrc: string; - animatedLayerSrc: string; - animationStyle: CSSProperties; - noColor?: boolean; -}) { - return ( - - - - - ); -} - -const StyledCTATitle = styled.span<{ reverseDirection: boolean }>` - font-size: var(--font-size-h4); - font-weight: bold; - line-height: normal; - display: inline-flex; - flex-direction: ${props => (props.reverseDirection ? 'row-reverse' : 'row')}; - align-items: center; - gap: var(--margins-xs); - padding: 3px; -`; - -const StyledFeatureList = styled.ul` - list-style: none; - padding-inline-start: 0; - text-align: start; - display: grid; - font-size: var(--font-size-lg); - grid-row-gap: var(--margins-md); - margin-block: 0; -`; - -const StyledListItem = styled.li` - display: inline-flex; - gap: var(--margins-sm); - align-items: end; - line-height: normal; -`; - -function FeatureListItem({ - children, - customIconSrc, -}: { - children: ReactNode; - customIconSrc?: string; -}) { - return ( - - {customIconSrc ? ( - - ) : ( - - )} - {children} - - ); -} - -function isVariantWithActionButton(variant: ProCTAVariant): boolean { - return ![ - ProCTAVariant.GROUP_NON_ADMIN, - ProCTAVariant.GROUP_ACTIVATED, - ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED, - ].includes(variant); -} - -// These CTAS have "Upgrade to" and "Renew" titles. -const variantsForNonGroupFeatures = [ - ProCTAVariant.MESSAGE_CHARACTER_LIMIT, - ProCTAVariant.ANIMATED_DISPLAY_PICTURE, - ProCTAVariant.PINNED_CONVERSATION_LIMIT, - ProCTAVariant.PINNED_CONVERSATION_LIMIT_GRANDFATHERED, - ProCTAVariant.GENERIC, -] as const; - -type VariantForNonGroupFeature = (typeof variantsForNonGroupFeatures)[number]; - -function isFeatureVariant(variant: ProCTAVariant): variant is VariantForNonGroupFeature { - return variantsForNonGroupFeatures.includes(variant as any); -} - -const variantsWithoutFeatureList = [ - ProCTAVariant.GROUP_NON_ADMIN, - ProCTAVariant.GROUP_ACTIVATED, - ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED, -] as const; - -type VariantWithoutFeatureList = (typeof variantsWithoutFeatureList)[number]; -type VariantWithFeatureList = Exclude; - -function isProFeatureListCTA(variant: ProCTAVariant): variant is VariantWithFeatureList { - return !variantsWithoutFeatureList.includes(variant as any); -} - -enum ProFeatureKey { - LONGER_MESSAGES = 'proFeatureListLongerMessages', - MORE_PINNED_CONVOS = 'proFeatureListPinnedConversations', - ANIMATED_DP = 'proFeatureListAnimatedDisplayPicture', - LARGER_GROUPS = 'proFeatureListLargerGroups', -} - -function getBaseFeatureList(variant: VariantWithFeatureList) { - switch (variant) { - case ProCTAVariant.MESSAGE_CHARACTER_LIMIT: - return [ProFeatureKey.LONGER_MESSAGES, ProFeatureKey.MORE_PINNED_CONVOS]; - - case ProCTAVariant.ANIMATED_DISPLAY_PICTURE: - return [ProFeatureKey.ANIMATED_DP, ProFeatureKey.LONGER_MESSAGES]; - - case ProCTAVariant.PINNED_CONVERSATION_LIMIT: - case ProCTAVariant.PINNED_CONVERSATION_LIMIT_GRANDFATHERED: - return [ProFeatureKey.MORE_PINNED_CONVOS, ProFeatureKey.LONGER_MESSAGES]; - - case ProCTAVariant.GENERIC: // yes generic has the same as above, reversed... - return [ProFeatureKey.LONGER_MESSAGES, ProFeatureKey.MORE_PINNED_CONVOS]; - - case ProCTAVariant.EXPIRING_SOON: - case ProCTAVariant.EXPIRED: - return [ - ProFeatureKey.LONGER_MESSAGES, - ProFeatureKey.MORE_PINNED_CONVOS, - ProFeatureKey.ANIMATED_DP, - ]; - - case ProCTAVariant.GROUP_ADMIN: - return [ProFeatureKey.LARGER_GROUPS, ProFeatureKey.LONGER_MESSAGES]; - - default: - assertUnreachable(variant, 'getFeatureList unreachable case'); - throw new Error('unreachable'); - } -} - -function FeatureList({ variant }: { variant: ProCTAVariant }) { - const featureList = useMemo(() => { - if (!isProFeatureListCTA(variant)) { - return []; - } - const features = getBaseFeatureList(variant).map(token => ( - {tr(token)} - )); - - // Expiry related CTAs dont show the "more" feature item - if (variant !== ProCTAVariant.EXPIRED && variant !== ProCTAVariant.EXPIRING_SOON) { - features.push( - - {tr('proFeatureListLoadsMore')} - - ); - } - return features; - }, [variant]); - - return featureList.length ? {featureList} : null; -} - -function ProExpiringSoonDescription() { - const { data } = useProAccessDetails(); - return ; -} - -function getDescription(variant: ProCTAVariant, userHasProExpired: boolean): ReactNode { - switch (variant) { - case ProCTAVariant.PINNED_CONVERSATION_LIMIT: - return ( - - ); - - case ProCTAVariant.PINNED_CONVERSATION_LIMIT_GRANDFATHERED: - return ( - - ); - - case ProCTAVariant.ANIMATED_DISPLAY_PICTURE: - return ( - - ); - - case ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED: - return ( - <> - - {' '} - - -
- - - ); - - case ProCTAVariant.MESSAGE_CHARACTER_LIMIT: - return ( - - ); - - case ProCTAVariant.GENERIC: - return ( - - ); - - case ProCTAVariant.EXPIRING_SOON: - return ; - - case ProCTAVariant.EXPIRED: - return ; - - // TODO: Group CTA string dont all exist yet and need to be implemented later - case ProCTAVariant.GROUP_ADMIN: - case ProCTAVariant.GROUP_NON_ADMIN: - case ProCTAVariant.GROUP_ACTIVATED: - return ( - - {' '} - - - ); - - default: - assertUnreachable(variant, 'getDescription unreachable case'); - throw new Error('unreachable'); - } -} - -function getImage(variant: ProCTAVariant): ReactNode { - switch (variant) { - case ProCTAVariant.PINNED_CONVERSATION_LIMIT: - case ProCTAVariant.PINNED_CONVERSATION_LIMIT_GRANDFATHERED: - return ; - - case ProCTAVariant.ANIMATED_DISPLAY_PICTURE: - case ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED: - return ( - - ); - - case ProCTAVariant.MESSAGE_CHARACTER_LIMIT: - return ; - - // TODO: Group CTA images dont exist yet and need to be implemented later - case ProCTAVariant.GROUP_ADMIN: - case ProCTAVariant.GROUP_NON_ADMIN: - case ProCTAVariant.GROUP_ACTIVATED: - return ; - - case ProCTAVariant.GENERIC: - case ProCTAVariant.EXPIRING_SOON: - case ProCTAVariant.EXPIRED: - return ( - - ); - - default: - assertUnreachable(variant, 'getImage'); - throw new Error('unreachable'); - } -} - -function CtaTitle({ variant }: { variant: ProCTAVariant }) { - const userHasExpiredPro = useCurrentUserHasExpiredPro(); - - const titleText = useMemo(() => { - if (isFeatureVariant(variant)) { - return ; - } - - switch (variant) { - // TODO: Group CTA titles arent finalised and need to be implemneted later - case ProCTAVariant.GROUP_NON_ADMIN: - return ; - - case ProCTAVariant.GROUP_ADMIN: - return ; - - case ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED: - return ; - - case ProCTAVariant.GROUP_ACTIVATED: - return ; - - case ProCTAVariant.EXPIRING_SOON: - return ; - - case ProCTAVariant.EXPIRED: - return ; - - default: - assertUnreachable(variant, 'CtaTitle'); - throw new Error('unreachable'); - } - }, [variant, userHasExpiredPro]); - - const isTitleDirectionReversed = useMemo(() => { - return [ - ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED, - ProCTAVariant.GROUP_ACTIVATED, - ProCTAVariant.EXPIRING_SOON, - ProCTAVariant.EXPIRED, - ].includes(variant); - }, [variant]); - - return ( - - {titleText} - - - ); -} - -// TODO: we might want to make this a specific button preset. As its used for all pro/sesh stuff -export const proButtonProps = { - buttonShape: SessionButtonShape.Square, - buttonType: SessionButtonType.Solid, - fontWeight: 400, - style: { - height: '46px', - width: '100%', - }, -} satisfies SessionButtonProps; - -function Buttons({ - variant, - onClose, - afterActionButtonCallback, - actionButtonNextModalAfterCloseCallback, -}: { - variant: ProCTAVariant; - onClose: () => void; - afterActionButtonCallback?: () => void; - actionButtonNextModalAfterCloseCallback?: () => void; -}) { - const dispatch = useDispatch(); - - const actionButton = useMemo(() => { - if (!isVariantWithActionButton(variant)) { - return null; - } - - let settingsModalProps: UserSettingsModalState = { - userSettingsPage: 'pro', - hideBackButton: true, - fromCTA: true, - centerAlign: true, - afterCloseAction: actionButtonNextModalAfterCloseCallback, - }; - - let buttonTextKey: MergedLocalizerTokens = 'theContinue'; - - if (variant === ProCTAVariant.EXPIRED || variant === ProCTAVariant.EXPIRING_SOON) { - settingsModalProps = { - userSettingsPage: 'proNonOriginating', - nonOriginatingVariant: variant === ProCTAVariant.EXPIRED ? 'renew' : 'update', - hideBackButton: true, - centerAlign: true, - afterCloseAction: actionButtonNextModalAfterCloseCallback, - }; - - buttonTextKey = variant === ProCTAVariant.EXPIRED ? 'renew' : 'update'; - } - - return ( - { - onClose(); - dispatch(userSettingsModal(settingsModalProps)); - afterActionButtonCallback?.(); - }} - dataTestId="modal-session-pro-confirm-button" - > - {tr(buttonTextKey)} - - ); - }, [ - variant, - dispatch, - onClose, - actionButtonNextModalAfterCloseCallback, - afterActionButtonCallback, - ]); - - return ( - - {actionButton} - - {tr(actionButton && variant !== ProCTAVariant.EXPIRING_SOON ? 'cancel' : 'close')} - - - ); -} - -export function SessionProInfoModal(props: SessionProInfoState) { - const dispatch = useDispatch(); - const hasPro = useCurrentUserHasPro(); - const userHasExpiredPro = useCurrentUserHasExpiredPro(); - - function onClose() { - dispatch(updateSessionProInfoModal(null)); - } - - // NOTE: Feature CTAs shouldnt show for users with pro - if (isNil(props?.variant) || (hasPro && isFeatureVariant(props.variant))) { - return null; - } - - return ( - - } - > - - - - - - {getDescription(props.variant, userHasExpiredPro)} - - - - - - ); -} - -export const showSessionProInfoDialog = (variant: ProCTAVariant, dispatch: Dispatch) => { - dispatch( - updateSessionProInfoModal({ - variant, - }) - ); -}; - -export const useShowSessionProInfoDialogCb = (variant: ProCTAVariant) => { - const dispatch = useDispatch(); - - // TODO: remove once pro is released - const isProAvailable = useIsProAvailable(); - if (!isProAvailable) { - return () => null; - } - - return () => showSessionProInfoDialog(variant, dispatch); -}; - -export const useShowSessionProInfoDialogCbWithVariant = () => { - const dispatch = useDispatch(); - - // TODO: remove once pro is released - const isProAvailable = useIsProAvailable(); - if (!isProAvailable) { - return (_: ProCTAVariant) => null; - } - - return (variant: ProCTAVariant) => showSessionProInfoDialog(variant, dispatch); -}; - -export async function handleProTriggeredCTAs(dispatch: Dispatch) { - if (Storage.get(SettingsKey.proExpiringSoonCTA)) { - dispatch( - updateSessionProInfoModal({ - variant: ProCTAVariant.EXPIRING_SOON, - }) - ); - await Storage.put(SettingsKey.proExpiringSoonCTA, false); - } else if (Storage.get(SettingsKey.proExpiredCTA)) { - dispatch( - updateSessionProInfoModal({ - variant: ProCTAVariant.EXPIRED, - }) - ); - await Storage.put(SettingsKey.proExpiredCTA, false); - } -} diff --git a/ts/components/dialog/conversationSettings/conversationSettingsItems.tsx b/ts/components/dialog/conversationSettings/conversationSettingsItems.tsx index c393975038..3ca65aca38 100644 --- a/ts/components/dialog/conversationSettings/conversationSettingsItems.tsx +++ b/ts/components/dialog/conversationSettings/conversationSettingsItems.tsx @@ -41,6 +41,7 @@ import { useGroupCommonNoShow } from '../../menuAndSettingsHooks/useGroupCommonN import { useShowConversationSettingsFor } from '../../menuAndSettingsHooks/useShowConversationSettingsFor'; import { useShowNoteToSelfCb } from '../../menuAndSettingsHooks/useShowNoteToSelf'; import { useTogglePinConversationHandler } from '../../menuAndSettingsHooks/UseTogglePinConversationHandler'; +import { PLURAL_COUNT_OTHER } from '../../../localization/localeTools'; type WithAsAdmin = { asAdmin: boolean }; @@ -289,7 +290,7 @@ export function AddAdminCommunityButton({ conversationId }: WithConvoId) { iconColor="var(--text-primary-color" /> } - text={{ token: 'addAdmins' }} + text={{ token: 'addAdmin', count: PLURAL_COUNT_OTHER }} onClick={cb} dataTestId="add-admins-menu-option" /> diff --git a/ts/components/dialog/cta/types.ts b/ts/components/dialog/cta/types.ts new file mode 100644 index 0000000000..3387f870c5 --- /dev/null +++ b/ts/components/dialog/cta/types.ts @@ -0,0 +1,59 @@ +export enum CTAVariant { + // NOTE: -- Pro CTAs -- + PRO_GENERIC = 1, + + // Pro Features - has expired sub variants + PRO_MESSAGE_CHARACTER_LIMIT = 2, + PRO_ANIMATED_DISPLAY_PICTURE = 3, + PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED = 4, + PRO_PINNED_CONVERSATION_LIMIT = 5, + PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED = 6, + + // Pro Groups + PRO_GROUP_NON_ADMIN = 30, + PRO_GROUP_ADMIN = 31, + PRO_GROUP_ACTIVATED = 32, + + // Pro Triggered + PRO_EXPIRING_SOON = 40, + PRO_EXPIRED = 41, + + // -- NOTE: -- Other CTAs -- + DONATE_GENERIC = 100, +} + +const proCTAVariants = [ + CTAVariant.PRO_GENERIC, + CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT, + CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE, + CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED, + CTAVariant.PRO_PINNED_CONVERSATION_LIMIT, + CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED, + CTAVariant.PRO_GROUP_NON_ADMIN, + CTAVariant.PRO_GROUP_ADMIN, + CTAVariant.PRO_GROUP_ACTIVATED, + CTAVariant.PRO_EXPIRING_SOON, + CTAVariant.PRO_EXPIRED, +] as const; + +export type ProCTAVariant = (typeof proCTAVariants)[number]; + +export const isProCTAVariant = (v: CTAVariant): v is ProCTAVariant => + proCTAVariants.includes(v as number); + +export type CTAVariantExcludingProCTAs = Exclude; + +// These CTAS have "Upgrade to" and "Renew" titles. +const variantsForNonGroupFeatures = [ + CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT, + CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE, + CTAVariant.PRO_PINNED_CONVERSATION_LIMIT, + CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED, + CTAVariant.PRO_GENERIC, +] as const; + +type VariantForNonGroupFeature = (typeof variantsForNonGroupFeatures)[number]; + +export function isProCTAFeatureVariant(variant: CTAVariant): variant is VariantForNonGroupFeature { + return variantsForNonGroupFeatures.includes(variant as number); +} diff --git a/ts/components/dialog/debug/DebugMenuModal.tsx b/ts/components/dialog/debug/DebugMenuModal.tsx index 371401fdb8..63a8f922c5 100644 --- a/ts/components/dialog/debug/DebugMenuModal.tsx +++ b/ts/components/dialog/debug/DebugMenuModal.tsx @@ -8,6 +8,7 @@ import { AboutInfo, DataGenerationActions, DebugActions, + DebugUrlInteractionsSection, ExperimentalActions, LoggingDebugSection, OtherInfo, @@ -137,6 +138,7 @@ function MainPage({ setPage }: DebugMenuPageProps) { {isDebug ? : null} + {isDebug ? : null}
@@ -209,6 +211,7 @@ export function DebugMenuModal() { allowOutsideClick={false} > { ); }; +export const DebugUrlInteractionsSection = () => { + const [urlInteractions, setUrlInteractions] = useState(getUrlInteractions()); + + const refresh = useCallback(() => setUrlInteractions(getUrlInteractions()), []); + const removeUrl = useCallback(async (url: string) => removeUrlInteractionHistory(url), []); + const clearAll = useCallback(async () => { + await clearAllUrlInteractions(); + refresh(); + }, [refresh]); + + return ( + + + Clear All + + Refresh + + + + + + + {urlInteractions.map(({ url, interactions, lastUpdated }) => { + const updatedStr = formatRoundedUpTimeUntilTimestamp(lastUpdated); + return ( + + {' '} + {' '} + + + ); + })} +
URLInteractionsLast Updated
{url} {interactions.map(urlInteractionToString).join(', ')}{updatedStr} + removeUrl(url)} + > + + +
+
+ ); +}; + export const ExperimentalActions = ({ forceUpdate }: { forceUpdate: () => void }) => { const dispatch = useDispatch(); // const refreshedAt = useReleasedFeaturesRefreshedAt(); diff --git a/ts/components/dialog/debug/playgrounds/CTAPlaygroundPage.tsx b/ts/components/dialog/debug/playgrounds/CTAPlaygroundPage.tsx new file mode 100644 index 0000000000..fbf9c14af3 --- /dev/null +++ b/ts/components/dialog/debug/playgrounds/CTAPlaygroundPage.tsx @@ -0,0 +1,69 @@ +import useUpdate from 'react-use/lib/useUpdate'; +import { ProDebugSection } from '../FeatureFlags'; +import { SpacerLG } from '../../../basic/Text'; +import { useShowSessionCTACbWithVariant } from '../../SessionCTA'; +import { Flex } from '../../../basic/Flex'; +import { LucideIcon } from '../../../icon/LucideIcon'; +import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; +import { DebugButton } from '../components'; +import { DebugMenuPageProps, DebugMenuSection } from '../DebugMenuModal'; +import { CTAVariant } from '../../cta/types'; + +export function CTAPlaygroundPage(props: DebugMenuPageProps) { + const forceUpdate = useUpdate(); + const handleClick = useShowSessionCTACbWithVariant(); + + return ( + <> + +

Call to Actions (CTAs)

+ + {' '} + {'Pro CTAs only work if pro is available, toggle it above!'} + + + +

Feature CTAs

+ handleClick(CTAVariant.PRO_GENERIC)}>Generic + handleClick(CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT)}> + Character Limit + + handleClick(CTAVariant.PRO_PINNED_CONVERSATION_LIMIT)}> + Pinned Conversations + + handleClick(CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED)} + > + Pinned Conversations (Grandfathered) + + handleClick(CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE)}> + Animated Profile Picture + + handleClick(CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED)}> + Animated Profile Picture (Has pro) + +

+ Pro Group CTAs WIP +

+ handleClick(CTAVariant.PRO_GROUP_ACTIVATED)}> + Group Activated + + handleClick(CTAVariant.PRO_GROUP_NON_ADMIN)}> + Group (Non-Admin) + + handleClick(CTAVariant.PRO_GROUP_ADMIN)}> + Group (Admin) + +

Special CTAs

+ handleClick(CTAVariant.PRO_EXPIRING_SOON)}> + Expiring Soon + + handleClick(CTAVariant.PRO_EXPIRED)}>Expired +
+ + ); +} diff --git a/ts/components/dialog/debug/playgrounds/ProPlaygroundPage.tsx b/ts/components/dialog/debug/playgrounds/ProPlaygroundPage.tsx index 914908ea2c..fa78d7eb12 100644 --- a/ts/components/dialog/debug/playgrounds/ProPlaygroundPage.tsx +++ b/ts/components/dialog/debug/playgrounds/ProPlaygroundPage.tsx @@ -1,16 +1,17 @@ import useUpdate from 'react-use/lib/useUpdate'; import { ProDebugSection } from '../FeatureFlags'; import { SpacerLG } from '../../../basic/Text'; -import { ProCTAVariant, useShowSessionProInfoDialogCbWithVariant } from '../../SessionProInfoModal'; +import { useShowSessionCTACbWithVariant } from '../../SessionCTA'; import { Flex } from '../../../basic/Flex'; import { LucideIcon } from '../../../icon/LucideIcon'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { DebugButton } from '../components'; import { DebugMenuPageProps, DebugMenuSection } from '../DebugMenuModal'; +import { CTAVariant } from '../../cta/types'; export function ProPlaygroundPage(props: DebugMenuPageProps) { const forceUpdate = useUpdate(); - const handleClick = useShowSessionProInfoDialogCbWithVariant(); + const handleClick = useShowSessionCTACbWithVariant(); return ( <> @@ -27,41 +28,41 @@ export function ProPlaygroundPage(props: DebugMenuPageProps) {

Feature CTAs

- handleClick(ProCTAVariant.GENERIC)}>Generic - handleClick(ProCTAVariant.MESSAGE_CHARACTER_LIMIT)}> + handleClick(CTAVariant.PRO_GENERIC)}>Generic + handleClick(CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT)}> Character Limit - handleClick(ProCTAVariant.PINNED_CONVERSATION_LIMIT)}> + handleClick(CTAVariant.PRO_PINNED_CONVERSATION_LIMIT)}> Pinned Conversations handleClick(ProCTAVariant.PINNED_CONVERSATION_LIMIT_GRANDFATHERED)} + onClick={() => handleClick(CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED)} > Pinned Conversations (Grandfathered) - handleClick(ProCTAVariant.ANIMATED_DISPLAY_PICTURE)}> + handleClick(CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE)}> Animated Profile Picture - handleClick(ProCTAVariant.ANIMATED_DISPLAY_PICTURE_ACTIVATED)}> + handleClick(CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED)}> Animated Profile Picture (Has pro)

Pro Group CTAs WIP

- handleClick(ProCTAVariant.GROUP_ACTIVATED)}> + handleClick(CTAVariant.PRO_GROUP_ACTIVATED)}> Group Activated - handleClick(ProCTAVariant.GROUP_NON_ADMIN)}> + handleClick(CTAVariant.PRO_GROUP_NON_ADMIN)}> Group (Non-Admin) - handleClick(ProCTAVariant.GROUP_ADMIN)}> + handleClick(CTAVariant.PRO_GROUP_ADMIN)}> Group (Admin)

Special CTAs

- handleClick(ProCTAVariant.EXPIRING_SOON)}> + handleClick(CTAVariant.PRO_EXPIRING_SOON)}> Expiring Soon - handleClick(ProCTAVariant.EXPIRED)}>Expired + handleClick(CTAVariant.PRO_EXPIRED)}>Expired
); diff --git a/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx b/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx index b058000bc0..ecbee53c51 100644 --- a/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx +++ b/ts/components/dialog/user-settings/pages/DefaultSettingsPage.tsx @@ -42,7 +42,7 @@ import { useProAccessDetails, } from '../../../../hooks/useHasPro'; import { NetworkTime } from '../../../../util/NetworkTime'; -import { DURATION_SECONDS } from '../../../../session/constants'; +import { APP_URL, DURATION_SECONDS } from '../../../../session/constants'; import { getFeatureFlag } from '../../../../state/ducks/types/releasedFeaturesReduxTypes'; import { useUserSettingsCloseAction } from './userSettingsHooks'; @@ -146,7 +146,7 @@ function MiscSection() { } text={{ token: 'donate' }} onClick={() => { - showLinkVisitWarningDialog('https://session.foundation/donate#app', dispatch); + showLinkVisitWarningDialog(APP_URL.DONATE, dispatch); }} dataTestId="donate-settings-menu-item" /> diff --git a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx index cf1862c326..ed537586a5 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx @@ -16,7 +16,7 @@ import { LucideIcon } from '../../../../icon/LucideIcon'; import { LUCIDE_ICONS_UNICODE, WithLucideUnicode } from '../../../../icon/lucide'; import { SessionButton, SessionButtonColor } from '../../../../basic/SessionButton'; import { showLinkVisitWarningDialog } from '../../../OpenUrlModal'; -import { proButtonProps } from '../../../SessionProInfoModal'; +import { proButtonProps } from '../../../SessionCTA'; import { Flex } from '../../../../basic/Flex'; import type { ProNonOriginatingPageVariant } from '../../../../../types/ReduxTypes'; import { useCurrentNeverHadPro, useProAccessDetails } from '../../../../../hooks/useHasPro'; diff --git a/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx b/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx index 91790a4330..884fe52ca5 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProSettingsPage.tsx @@ -42,7 +42,7 @@ import { useProAccessDetails, } from '../../../../../hooks/useHasPro'; import { SessionButton, SessionButtonColor } from '../../../../basic/SessionButton'; -import { proButtonProps } from '../../../SessionProInfoModal'; +import { proButtonProps } from '../../../SessionCTA'; import { useIsProGroupsAvailable } from '../../../../../hooks/useIsProAvailable'; import { SpacerMD } from '../../../../basic/Text'; import LIBSESSION_CONSTANTS from '../../../../../session/utils/libsession/libsession_constants'; diff --git a/ts/components/dialog/user-settings/pages/userSettingsHooks.tsx b/ts/components/dialog/user-settings/pages/userSettingsHooks.tsx index 01e51ed486..faa3a6e331 100644 --- a/ts/components/dialog/user-settings/pages/userSettingsHooks.tsx +++ b/ts/components/dialog/user-settings/pages/userSettingsHooks.tsx @@ -6,7 +6,7 @@ import { type UserSettingsPage, } from '../../../../state/ducks/modalDialog'; import { assertUnreachable } from '../../../../types/sqlSharedTypes'; -import { handleProTriggeredCTAs } from '../../SessionProInfoModal'; +import { handleTriggeredProCTAs } from '../../SessionCTA'; export function useUserSettingsTitle(page: UserSettingsModalState | undefined) { if (!page) { @@ -80,7 +80,7 @@ export function useUserSettingsCloseAction(props: UserSettingsModalState) { case 'proNonOriginating': return () => { dispatch(userSettingsModal(null)); - void handleProTriggeredCTAs(dispatch); + void handleTriggeredProCTAs(dispatch); props.afterCloseAction?.(); }; diff --git a/ts/components/leftpane/ActionsPanel.tsx b/ts/components/leftpane/ActionsPanel.tsx index 7eacd64fd4..edc162209b 100644 --- a/ts/components/leftpane/ActionsPanel.tsx +++ b/ts/components/leftpane/ActionsPanel.tsx @@ -61,6 +61,8 @@ import { useDebugMenuModal } from '../../state/selectors/modal'; import { useFeatureFlag } from '../../state/ducks/types/releasedFeaturesReduxTypes'; import { useDebugKey } from '../../hooks/useDebugKey'; import { UpdateProRevocationList } from '../../session/utils/job_runners/jobs/UpdateProRevocationListJob'; +import { proBackendDataActions } from '../../state/ducks/proBackendData'; +import { handleTriggeredProCTAs } from '../dialog/SessionCTA'; const StyledContainerAvatar = styled.div` padding: var(--margins-lg); @@ -114,6 +116,12 @@ const doAppStartUp = async () => { void SnodePool.getFreshSwarmFor(UserUtils.getOurPubKeyStrFromCache()).then(() => { // trigger any other actions that need to be done after the swarm is ready window.inboxStore?.dispatch(networkDataActions.fetchInfoFromSeshServer() as any); + window.inboxStore?.dispatch( + proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any + ); + if (window.inboxStore) { + void handleTriggeredProCTAs(window.inboxStore.dispatch); + } }); // refresh our swarm on start to speed up the first message fetching event void Data.cleanupOrphanedAttachments(); diff --git a/ts/components/menuAndSettingsHooks/UseTogglePinConversationHandler.tsx b/ts/components/menuAndSettingsHooks/UseTogglePinConversationHandler.tsx index 37ee0a40ba..37975b30ab 100644 --- a/ts/components/menuAndSettingsHooks/UseTogglePinConversationHandler.tsx +++ b/ts/components/menuAndSettingsHooks/UseTogglePinConversationHandler.tsx @@ -8,14 +8,12 @@ import { useIsPrivate, useIsPrivateAndFriend, } from '../../hooks/useParamSelector'; -import { - ProCTAVariant, - useShowSessionProInfoDialogCbWithVariant, -} from '../dialog/SessionProInfoModal'; +import { useShowSessionCTACbWithVariant } from '../dialog/SessionCTA'; import { Constants } from '../../session'; import { getPinnedConversationsCount } from '../../state/selectors/conversations'; import { useIsMessageRequestOverlayShown } from '../../state/selectors/section'; import { useCurrentUserHasPro } from '../../hooks/useHasPro'; +import { CTAVariant } from '../dialog/cta/types'; function useShowPinUnpin(conversationId: string) { const isPrivateAndFriend = useIsPrivateAndFriend(conversationId); @@ -46,7 +44,7 @@ export function useTogglePinConversationHandler(id: string) { const isProAvailable = useIsProAvailable(); const hasPro = useCurrentUserHasPro(); - const handleShowProDialog = useShowSessionProInfoDialogCbWithVariant(); + const handleShowProDialog = useShowSessionCTACbWithVariant(); const showPinUnpin = useShowPinUnpin(id); @@ -66,7 +64,7 @@ export function useTogglePinConversationHandler(id: string) { return () => handleShowProDialog( pinnedConversationsCount > Constants.CONVERSATION.MAX_PINNED_CONVERSATIONS_STANDARD - ? ProCTAVariant.PINNED_CONVERSATION_LIMIT_GRANDFATHERED - : ProCTAVariant.PINNED_CONVERSATION_LIMIT + ? CTAVariant.PRO_PINNED_CONVERSATION_LIMIT_GRANDFATHERED + : CTAVariant.PRO_PINNED_CONVERSATION_LIMIT ); } diff --git a/ts/components/menuAndSettingsHooks/useProBadgeOnClickCb.tsx b/ts/components/menuAndSettingsHooks/useProBadgeOnClickCb.tsx index b89fc95ebb..39457beefd 100644 --- a/ts/components/menuAndSettingsHooks/useProBadgeOnClickCb.tsx +++ b/ts/components/menuAndSettingsHooks/useProBadgeOnClickCb.tsx @@ -1,13 +1,11 @@ import { useDispatch } from 'react-redux'; import { useIsProAvailable } from '../../hooks/useIsProAvailable'; import { ProMessageFeature } from '../../models/proMessageFeature'; -import { SessionProInfoState, updateSessionProInfoModal } from '../../state/ducks/modalDialog'; +import { SessionCTAState, updateSessionCTA } from '../../state/ducks/modalDialog'; import { assertUnreachable } from '../../types/sqlSharedTypes'; import type { ContactNameContext } from '../conversation/ContactName/ContactNameContext'; -import { - ProCTAVariant, - useShowSessionProInfoDialogCbWithVariant, -} from '../dialog/SessionProInfoModal'; +import { useShowSessionCTACbWithVariant } from '../dialog/SessionCTA'; +import { CTAVariant } from '../dialog/cta/types'; type WithUserHasPro = { userHasPro: boolean }; type WithMessageSentWithProFeat = { messageSentWithProFeat: Array | null }; @@ -18,7 +16,7 @@ type WithContactNameContext = { contactNameContext: ContactNameContext }; type WithIsGroupV2 = { isGroupV2: boolean }; type WithIsBlinded = { isBlinded: boolean }; type WithProvidedCb = { providedCb: (() => void) | null }; -type WithProCTA = { cta: SessionProInfoState }; +type WithProCTA = { cta: SessionCTAState }; type ProBadgeContext = | { context: 'edit-profile-pic'; args: WithProCTA } @@ -99,14 +97,14 @@ function isContactNameNoShowContext(context: ContactNameContext) { } } -function proFeatureToVariant(proFeature: ProMessageFeature): ProCTAVariant { +function proFeatureToVariant(proFeature: ProMessageFeature): CTAVariant { switch (proFeature) { case ProMessageFeature.PRO_INCREASED_MESSAGE_LENGTH: - return ProCTAVariant.MESSAGE_CHARACTER_LIMIT; + return CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT; case ProMessageFeature.PRO_ANIMATED_DISPLAY_PICTURE: - return ProCTAVariant.ANIMATED_DISPLAY_PICTURE; + return CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE; case ProMessageFeature.PRO_BADGE: - return ProCTAVariant.GENERIC; + return CTAVariant.PRO_GENERIC; default: assertUnreachable(proFeature, 'ProFeatureToVariant: unknown case'); throw new Error('unreachable'); @@ -124,7 +122,7 @@ export function useProBadgeOnClickCb( opts: ProBadgeContext ): ShowTagWithCb | ShowTagNoCb | DoNotShowTag { const dispatch = useDispatch(); - const handleShowProInfoModal = useShowSessionProInfoDialogCbWithVariant(); + const handleShowProInfoModal = useShowSessionCTACbWithVariant(); const isProAvailable = useIsProAvailable(); if (!isProAvailable) { @@ -137,7 +135,7 @@ export function useProBadgeOnClickCb( if (context === 'edit-profile-pic') { return { show: true, - cb: () => dispatch(updateSessionProInfoModal(args.cta)), + cb: () => dispatch(updateSessionCTA(args.cta)), }; } @@ -171,7 +169,7 @@ export function useProBadgeOnClickCb( cb: () => { handleShowProInfoModal( multiProFeatUsed - ? ProCTAVariant.GENERIC + ? CTAVariant.PRO_GENERIC : proFeatureToVariant(messageSentWithProFeat[0]) ); }, @@ -202,7 +200,7 @@ export function useProBadgeOnClickCb( // if this is a groupv2, the badge should open the "groupv2 activated" modal onclick return { show: true, - cb: () => handleShowProInfoModal(ProCTAVariant.GROUP_ACTIVATED), + cb: () => handleShowProInfoModal(CTAVariant.PRO_GROUP_ACTIVATED), }; } @@ -211,7 +209,7 @@ export function useProBadgeOnClickCb( return showNoCb; } // FOMO: user shown has pro but we don't: show CTA on click - return { show: true, cb: () => handleShowProInfoModal(ProCTAVariant.GENERIC) }; + return { show: true, cb: () => handleShowProInfoModal(CTAVariant.PRO_GENERIC) }; } if (context === 'character-count') { @@ -222,7 +220,7 @@ export function useProBadgeOnClickCb( // FOMO return { show: true, - cb: () => handleShowProInfoModal(ProCTAVariant.MESSAGE_CHARACTER_LIMIT), + cb: () => handleShowProInfoModal(CTAVariant.PRO_MESSAGE_CHARACTER_LIMIT), }; } @@ -255,7 +253,7 @@ export function useProBadgeOnClickCb( return showNoCb; } - return { show: true, cb: () => handleShowProInfoModal(ProCTAVariant.GENERIC) }; + return { show: true, cb: () => handleShowProInfoModal(CTAVariant.PRO_GENERIC) }; } return showNoCb; } diff --git a/ts/data/data.ts b/ts/data/data.ts index 00fa627c18..1ba273c310 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -60,6 +60,15 @@ async function getPasswordHash(): Promise { return channels.getPasswordHash(); } +// Note: once we have this timestamp there is no reason it should change +let cachedDBCreationTimestampMs: null | number = null; +async function getDBCreationTimestampMs(): Promise { + if (!cachedDBCreationTimestampMs) { + cachedDBCreationTimestampMs = await channels.getDBCreationTimestampMs(); + } + return cachedDBCreationTimestampMs; +} + // Guard Nodes async function getGuardNodes(): Promise> { return channels.getGuardNodes(); @@ -781,6 +790,7 @@ export const Data = { close, removeDB, getPasswordHash, + getDBCreationTimestampMs, // items table logic createOrUpdateItem, diff --git a/ts/data/dataInit.ts b/ts/data/dataInit.ts index 0f2ce7fb44..11c1a8811e 100644 --- a/ts/data/dataInit.ts +++ b/ts/data/dataInit.ts @@ -16,6 +16,7 @@ const channelsToMake = new Set([ 'close', 'removeDB', 'getPasswordHash', + 'getDBCreationTimestampMs', 'getGuardNodes', 'updateGuardNodes', 'createOrUpdateItem', diff --git a/ts/data/settings-key.ts b/ts/data/settings-key.ts index f265c6da97..9a6505709d 100644 --- a/ts/data/settings-key.ts +++ b/ts/data/settings-key.ts @@ -69,6 +69,7 @@ export const SettingsKey = { numberId: 'number_id', localAttachmentEncryptionKey, spellCheckEnabled: 'spell-check', + urlInteractions: 'urlInteractions', proMasterKeyHex: 'proMasterKeyHex', proRotatingPrivateKeyHex: 'proRotatingPrivateKeyHex', /** diff --git a/ts/localization/localeTools.ts b/ts/localization/localeTools.ts index 15e4269365..e9b584ecda 100644 --- a/ts/localization/localeTools.ts +++ b/ts/localization/localeTools.ts @@ -10,6 +10,12 @@ import { type TokensSimpleAndArgs, } from './locales'; +// NOTE: this forces a plural string to use the "1" variant +export const PLURAL_COUNT_ONE = 1; + +// NOTE: this forces a plural string to use the "other" variant +export const PLURAL_COUNT_OTHER = 99; + // Note: those two functions are actually duplicates of Errors.toString. // We should maybe make that a module that we reuse? function withClause(error: unknown) { diff --git a/ts/node/fs_utility.ts b/ts/node/fs_utility.ts new file mode 100644 index 0000000000..ef077c0b7f --- /dev/null +++ b/ts/node/fs_utility.ts @@ -0,0 +1,26 @@ +import fs from 'fs'; + +export function getFileCreationTimestampMs(filePath: string): number | null { + if (!filePath || typeof filePath !== 'string') { + console.warn(`Invalid file path provided ${filePath}`); + return null; + } + + try { + const stats = fs.statSync(filePath); + + if ( + typeof stats.birthtimeMs !== 'number' || + !Number.isFinite(stats.birthtimeMs) || + stats.birthtimeMs <= 0 + ) { + console.warn(`Birth time is not a valid number for file: ${filePath}`); + return null; + } + + return stats.birthtimeMs; + } catch (error) { + console.error(`Failed to get creation time for file ${filePath}`, error); + return null; + } +} diff --git a/ts/node/sql.ts b/ts/node/sql.ts index c2f312ad33..b989190985 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -86,6 +86,7 @@ import { } from './sqlInstance'; import { OpenGroupV2Room } from '../data/types'; import { tr } from '../localization/localeTools'; +import { getFileCreationTimestampMs } from './fs_utility'; // eslint:disable: function-name non-literal-fs-path @@ -271,6 +272,16 @@ function removePasswordHash() { removeItemById(PASS_HASH_ID); } +function getDBCreationTimestampMs(): number | null { + if (!databaseFilePath) { + return null; + } + if (process.env.DB_CREATION_TIMESTAMP) { + return Number.parseInt(process.env.DB_CREATION_TIMESTAMP, 10); + } + return getFileCreationTimestampMs(databaseFilePath); +} + function getIdentityKeyById(id: string, instance: BetterSqlite3.Database) { return getById(IDENTITY_KEYS_TABLE, id, instance); } @@ -2533,6 +2544,7 @@ export const sqlNode = { close, removeDB, setSQLPassword, + getDBCreationTimestampMs, getPasswordHash, savePasswordHash, diff --git a/ts/session/apis/seed_node_api/SeedNodeAPI.ts b/ts/session/apis/seed_node_api/SeedNodeAPI.ts index 9bd8c294f5..4d54b03dd4 100644 --- a/ts/session/apis/seed_node_api/SeedNodeAPI.ts +++ b/ts/session/apis/seed_node_api/SeedNodeAPI.ts @@ -1,7 +1,8 @@ import https from 'https'; import tls from 'tls'; - +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'net'; import _ from 'lodash'; + // eslint-disable-next-line import/no-named-default import { default as insecureNodeFetch } from 'node-fetch'; import pRetry from 'p-retry'; @@ -275,7 +276,10 @@ async function getSnodesFromSeedUrl(urlObj: URL): Promise> { agent: sslAgent, }; window?.log?.info(`insecureNodeFetch => plaintext for getSnodesFromSeedUrl ${url}`); - + // Note: node has a default timeout of 250ms to pick ipv4 or ipv6 address, but sometimes it times out + // Increase that duration to 500ms as it seems to be resolving our issues. + // see https://github.com/nodejs/undici/issues/2990#issuecomment-2408883876 + setDefaultAutoSelectFamilyAttemptTimeout(500); const response = await insecureNodeFetch(url, fetchOptions); if (response.status !== 200) { diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 2f31927d12..78f6bc6436 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -977,7 +977,7 @@ export class SwarmPolling { ); } - const allResultsFromUserProfile = await Promise.allSettled( + const firstResultWithMessagesUserProfile = await Promise.any( swarmSnodes.map(async toPollFrom => { // Note: always print something so we know if the polling is hanging window.log.info( @@ -996,21 +996,25 @@ export class SwarmPolling { window.log.info( `[onboarding] pollOnceForOurDisplayName of ${ed25519Str(pubkey.key)} from snode: ${ed25519Str(toPollFrom.pubkey_ed25519)} namespaces: ${[SnodeNamespaces.UserProfile]} returned: ${retrieved?.length}` ); + if (!retrieved?.length) { + /** + * Sometimes, a snode is out of sync with its swarm but still replies with what he thinks is the swarm's content. + * When that happens, we can get a "no display name" error, as indeed, that snode didn't have a config message on user profile. + * To fix this, we've added a check over all of the snodes of our swarm, and we pick the first one that reports having a config message on user profile. + * This won't take care of the case where a snode has a message with an empty display name, but it's not the root issue that this was added for. + */ + + throw new Error( + `pollOnceForOurDisplayName of ${ed25519Str(pubkey.key)} from snode: ${ed25519Str(toPollFrom.pubkey_ed25519)} no results from user profile` + ); + } return retrieved; }) ); - const resultsFromUserProfile = flatten( - compact( - allResultsFromUserProfile - .filter(promise => promise.status === 'fulfilled') - .map(promise => promise.value) - ) - ); - // check if we just fetched the details from the config namespaces. // If yes, merge them together and exclude them from the rest of the messages. - if (!resultsFromUserProfile?.length) { + if (!firstResultWithMessagesUserProfile?.length) { throw new NotFoundError('[pollOnceForOurDisplayName] resultsFromUserProfile is empty'); } @@ -1021,7 +1025,7 @@ export class SwarmPolling { } const userConfigMessagesWithNamespace: Array> = - resultsFromUserProfile.map(r => { + firstResultWithMessagesUserProfile.map(r => { return (r.messages.messages || []).map(m => { return { ...m, namespace: SnodeNamespaces.UserProfile }; }); diff --git a/ts/session/constants.ts b/ts/session/constants.ts index 672a6bcc0f..5de3d6fa9a 100644 --- a/ts/session/constants.ts +++ b/ts/session/constants.ts @@ -135,3 +135,7 @@ export const PASSWORD_LENGTH = { */ MAX_PASSWORD_LEN: 256, }; + +export enum APP_URL { + DONATE = 'https://getsession.org/donate', +} diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 3257f0de22..195bd0d439 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -33,7 +33,7 @@ import { sectionActions } from './section'; import { ed25519Str } from '../../session/utils/String'; import { UserUtils } from '../../session/utils'; import type { ProMessageFeature } from '../../models/proMessageFeature'; -import { handleProTriggeredCTAs } from '../../components/dialog/SessionProInfoModal'; +import { handleTriggeredProCTAs } from '../../components/dialog/SessionCTA'; export type MessageModelPropsWithoutConvoProps = { propsForMessage: PropsForMessageWithoutConvoProps; @@ -1143,7 +1143,7 @@ export async function openConversationWithMessages(args: { window.inboxStore?.dispatch(sectionActions.resetRightOverlayMode()); if (window.inboxStore) { - await handleProTriggeredCTAs(window.inboxStore.dispatch); + await handleTriggeredProCTAs(window.inboxStore.dispatch); } } diff --git a/ts/state/ducks/modalDialog.tsx b/ts/state/ducks/modalDialog.tsx index 2dfb9815cd..24aca31f7a 100644 --- a/ts/state/ducks/modalDialog.tsx +++ b/ts/state/ducks/modalDialog.tsx @@ -12,9 +12,9 @@ import type { ProNonOriginatingPageVariant, } from '../../types/ReduxTypes'; import { WithConvoId } from '../../session/types/with'; -import type { ProCTAVariant } from '../../components/dialog/SessionProInfoModal'; import type { TrArgs } from '../../localization/localeTools'; import { SessionButtonType } from '../../components/basic/SessionButton'; +import type { CTAVariant } from '../../components/dialog/cta/types'; export type BanType = 'ban' | 'unban'; @@ -91,11 +91,11 @@ export type LocalizedPopupDialogState = { overrideButtons?: Array; } | null; -export type SessionProInfoState = { - variant: ProCTAVariant; +export type SessionCTAState = { + variant: CTAVariant; afterActionButtonCallback?: () => void; // If the action button opens another modal, this callback is called after that next modal is closed. - // For example: If "ProInfoModal" is opened from the "EditProfilePictureModal", and "ProInfoModal"'s + // For example: If "SessionCTA" is opened from the "EditProfilePictureModal", and "SessionCTA"'s // action button opens the "ProSettingsModal", we want to re-open "EditProfilePictureModal" // when "ProSettingsModal" closes. actionButtonNextModalAfterCloseCallback?: () => void; @@ -185,7 +185,7 @@ export type ModalState = { hideRecoveryPasswordModal: HideRecoveryPasswordModalState; openUrlModal: OpenUrlModalState; localizedPopupDialog: LocalizedPopupDialogState; - sessionProInfoModal: SessionProInfoState; + sessionProInfoModal: SessionCTAState; lightBoxOptions: LightBoxOptions; debugMenuModal: DebugMenuModalState; conversationSettingsModal: ConversationSettingsModalState; @@ -323,7 +323,7 @@ const ModalSlice = createSlice({ updateLocalizedPopupDialog(state, action: PayloadAction) { return pushOrPopModal(state, 'localizedPopupDialog', action.payload); }, - updateSessionProInfoModal(state, action: PayloadAction) { + updateSessionCTA(state, action: PayloadAction) { return pushOrPopModal(state, 'sessionProInfoModal', action.payload); }, updateLightBoxOptions(state, action: PayloadAction) { @@ -373,7 +373,7 @@ export const { updateHideRecoveryPasswordModal, updateOpenUrlModal, updateLocalizedPopupDialog, - updateSessionProInfoModal, + updateSessionCTA, updateLightBoxOptions, updateDebugMenuModal, updateConversationSettingsModal, diff --git a/ts/state/ducks/proBackendData.ts b/ts/state/ducks/proBackendData.ts index e59959e6bf..6e5c38de97 100644 --- a/ts/state/ducks/proBackendData.ts +++ b/ts/state/ducks/proBackendData.ts @@ -173,7 +173,7 @@ async function putProDetailsInStorage(details: ProDetailsResultType) { await Storage.put(SettingsKey.proDetails, details); } -async function handleNewProProof(rotatingPrivKeyHex: string) { +async function handleNewProProof(rotatingPrivKeyHex: string): Promise { const masterPrivKeyHex = await getProMasterKeyHex(); const response = await ProBackendAPI.generateProProof({ masterPrivKeyHex, @@ -188,13 +188,15 @@ async function handleNewProProof(rotatingPrivKeyHex: string) { signatureHex: response.result.sig, } satisfies ProProof; await UserConfigWrapperActions.setProConfig({ proProof, rotatingPrivKeyHex }); - } else { - window?.log?.error('failed to get new pro proof: ', response); + return proProof; } + window?.log?.error('failed to get new pro proof: ', response); + return null; } async function handleClearProProof() { - // TODO: remove pro proof from user config + await UserConfigWrapperActions.removeProConfig(); + // TODO: remove access expiry timestamp from synced user config } async function handleExpiryCTAs( @@ -210,6 +212,11 @@ async function handleExpiryCTAs( const proExpiringSoonCTA = !isUndefined(Storage.get(SettingsKey.proExpiringSoonCTA)); const proExpiredCTA = !isUndefined(Storage.get(SettingsKey.proExpiredCTA)); + // Remove the pro expired cta item if the user gets pro again + if (status === ProStatus.Active && proExpiredCTA) { + await Storage.remove(SettingsKey.proExpiredCTA); + } + if (now < sevenDaysBeforeExpiry) { // More than 7 days before expiry, remove CTA items if they exist. This means the items were set for a previous cycle of pro access. if (proExpiringSoonCTA) { @@ -227,10 +234,30 @@ async function handleExpiryCTAs( // Between expiry and 30 days after expiry, Expired CTA needs to be marked to be shown if not already if (status === ProStatus.Expired && !proExpiredCTA) { await Storage.put(SettingsKey.proExpiredCTA, true); + // The expiring soon CTA should be removed if it's set as we want to show it again in the future if needed + if (proExpiringSoonCTA) { + await Storage.remove(SettingsKey.proExpiringSoonCTA); + } } } } +let lastKnownProofExpiryTimestamp: number | null = null; +let scheduledProofExpiryTaskTimestamp: number | null = null; +let scheduledProofExpiryTaskId: ReturnType | null = null; +let scheduledAccessExpiryTaskTimestamp: number | null = null; +let scheduledAccessExpiryTaskId: ReturnType | null = null; + +function scheduleRefresh(timestampMs: number) { + const delay = Math.max(timestampMs - NetworkTime.now(), 15 * DURATION.SECONDS); + window?.log?.info(`Scheduling a pro details refresh in ${delay}ms for ${timestampMs}`); + return setTimeout(() => { + window?.inboxStore?.dispatch( + proBackendDataActions.refreshGetProDetailsFromProBackend({}) as any + ); + }, delay); +} + async function handleProProof(accessExpiryTsMs: number, autoRenewing: boolean, status: ProStatus) { if (status !== ProStatus.Active) { return; @@ -238,10 +265,22 @@ async function handleProProof(accessExpiryTsMs: number, autoRenewing: boolean, s const proConfig = await UserConfigWrapperActions.getProConfig(); + // TODO: if the user config access expiry timestamp is different, set it and sync the user config + + let proofExpiry: number | null = null; + if (!proConfig || !proConfig.proProof) { - const rotatingPrivKeyHex = await UserUtils.getProRotatingPrivateKeyHex(); - await handleNewProProof(rotatingPrivKeyHex); + try { + const rotatingPrivKeyHex = await UserUtils.getProRotatingPrivateKeyHex(); + const newProof = await handleNewProProof(rotatingPrivKeyHex); + if (newProof) { + proofExpiry = newProof.expiryMs; + } + } catch (e) { + window?.log?.error(e); + } } else { + proofExpiry = proConfig.proProof.expiryMs; const sixtyMinutesBeforeAccessExpiry = accessExpiryTsMs - DURATION.HOURS; const sixtyMinutesBeforeProofExpiry = proConfig.proProof.expiryMs - DURATION.HOURS; const now = NetworkTime.now(); @@ -251,8 +290,34 @@ async function handleProProof(accessExpiryTsMs: number, autoRenewing: boolean, s autoRenewing ) { const rotatingPrivKeyHex = proConfig.rotatingPrivKeyHex; - await handleNewProProof(rotatingPrivKeyHex); + const newProof = await handleNewProProof(rotatingPrivKeyHex); + if (newProof) { + proofExpiry = newProof.expiryMs; + } + } + } + + const accessExpiryRefreshTimestamp = accessExpiryTsMs + 30 * DURATION.SECONDS; + if (accessExpiryRefreshTimestamp !== scheduledAccessExpiryTaskTimestamp) { + if (scheduledAccessExpiryTaskId) { + clearTimeout(scheduledAccessExpiryTaskId); + } + scheduledAccessExpiryTaskTimestamp = accessExpiryRefreshTimestamp; + scheduledAccessExpiryTaskId = scheduleRefresh(scheduledAccessExpiryTaskTimestamp); + } + + if ( + proofExpiry && + (!scheduledProofExpiryTaskTimestamp || proofExpiry !== lastKnownProofExpiryTimestamp) + ) { + if (scheduledProofExpiryTaskId) { + clearTimeout(scheduledProofExpiryTaskId); } + // Random number of minutes between 10 and 60 + const minutes = Math.floor(Math.random() * 51) + 10; + lastKnownProofExpiryTimestamp = proofExpiry; + scheduledProofExpiryTaskTimestamp = proofExpiry - minutes * DURATION.MINUTES; + scheduledProofExpiryTaskId = scheduleRefresh(scheduledProofExpiryTaskTimestamp); } } diff --git a/ts/state/onboarding/ducks/modals.ts b/ts/state/onboarding/ducks/modals.ts index f8d5e27cfe..c8016b12f0 100644 --- a/ts/state/onboarding/ducks/modals.ts +++ b/ts/state/onboarding/ducks/modals.ts @@ -2,7 +2,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { TermsOfServicePrivacyDialogProps } from '../../../components/dialog/TermsOfServicePrivacyDialog'; import type { SessionConfirmDialogProps } from '../../../components/dialog/SessionConfirm'; import { - type SessionProInfoState, + type SessionCTAState, type OpenUrlModalState, type LocalizedPopupDialogState, } from '../../ducks/modalDialog'; @@ -20,7 +20,7 @@ export type ModalsState = { termsOfServicePrivacyModalState: TermsOfServicePrivacyModalState; openUrlModal: OpenUrlModalState; localizedPopupDialog: LocalizedPopupDialogState; - sessionProInfoModal: SessionProInfoState; + sessionProInfoModal: SessionCTAState; }; const initialState: ModalsState = { @@ -50,7 +50,7 @@ export const modalsSlice = createSlice({ updateLocalizedPopupDialogModal(state, action: PayloadAction) { return { ...state, localizedPopupDialog: action.payload }; }, - updateSessionProInfoModal(state, action: PayloadAction) { + updateSessionProInfoModal(state, action: PayloadAction) { return { ...state, sessionProInfoModal: action.payload }; }, }, diff --git a/ts/util/i18n/timeLocaleMap.ts b/ts/util/i18n/timeLocaleMap.ts index 7d324ab2bc..e39559f685 100644 --- a/ts/util/i18n/timeLocaleMap.ts +++ b/ts/util/i18n/timeLocaleMap.ts @@ -24,7 +24,6 @@ export const timeLocaleMap: Record = { en: supportedByDateFns.enUS, // then overwrite anything that we don't agree with or need to support specifically. - // @ts-expect-error - When building the locales with --en-only, this break tsc but is a non-issue 'es-419': supportedByDateFns.es, fa: supportedByDateFns.faIR, fil: supportedByDateFns.fi, diff --git a/ts/util/logger/rotatingPinoDest.ts b/ts/util/logger/rotatingPinoDest.ts index 90038a7400..d96e8dfef8 100644 --- a/ts/util/logger/rotatingPinoDest.ts +++ b/ts/util/logger/rotatingPinoDest.ts @@ -6,6 +6,7 @@ import pino from 'pino'; import { DURATION } from '../../session/constants'; import { LogLevel } from './Logging'; +import { getFileCreationTimestampMs } from '../../node/fs_utility'; /** * Keep at most rotated 3 files, so 4 files total including the "current" one @@ -52,10 +53,10 @@ export function createRotatingPinoDest({ function maybeRotate(startingIndex = maxSavedLogFiles - 1) { let pendingFileIndex = startingIndex; try { - const { birthtimeMs } = fs.statSync(logFile); + const fileCreationTimeMs = getFileCreationTimestampMs(logFile); // more recent than - if (birthtimeMs > Date.now() - ROTATION_INTERVAL) { + if (fileCreationTimeMs && fileCreationTimeMs > Date.now() - ROTATION_INTERVAL) { return; } diff --git a/ts/util/storage.ts b/ts/util/storage.ts index 06fc14b59d..1f55caa80e 100644 --- a/ts/util/storage.ts +++ b/ts/util/storage.ts @@ -5,6 +5,7 @@ import { deleteSettingsBoolValue, updateSettingsBoolValue } from '../state/ducks import { Data } from '../data/data'; import { SettingsKey } from '../data/settings-key'; import { ProProofResultType, ProDetailsResultType } from '../session/apis/pro_backend_api/schemas'; +import { UrlInteractionsType } from './urlHistory'; let ready = false; @@ -15,6 +16,7 @@ type ValueType = | boolean | SessionKeyPair | Array + | UrlInteractionsType | ProDetailsResultType | ProProofResultType; type InsertedValueType = { id: string; value: ValueType }; diff --git a/ts/util/urlHistory.ts b/ts/util/urlHistory.ts new file mode 100644 index 0000000000..264f4dcb1c --- /dev/null +++ b/ts/util/urlHistory.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { SettingsKey } from '../data/settings-key'; +import { Storage } from './storage'; +import { tr } from '../localization/localeTools'; + +// NOTE: we currently only want to use url interactions for official urls. Once we have the ability to "trust" a url this can change +function isValidUrl(url: string): boolean { + if (!URL.canParse(url)) { + return false; + } + + const host = new URL(url).host; + return ( + host === 'getsession.org' || + host === 'session.foundation' || + host === 'token.getsession.org' || + host === 'stake.getsession.org' + ); +} + +export enum URLInteraction { + OPEN = 1, + COPY = 2, + TRUST = 3, +} + +const UrlInteractionSchema = z.object({ + url: z.string(), + lastUpdated: z.number(), + interactions: z.array(z.nativeEnum(URLInteraction)), +}); + +const UrlInteractionsSchema = z.array(UrlInteractionSchema); + +export type UrlInteractionsType = z.infer; + +export function getUrlInteractions() { + let interactions: UrlInteractionsType = []; + const rawInteractions = Storage.get(SettingsKey.urlInteractions); + const result = UrlInteractionsSchema.safeParse(rawInteractions); + if (result.error) { + window?.log?.error(`failed to parse ${SettingsKey.urlInteractions}`, result.error); + } else { + interactions = result.data; + } + return interactions; +} + +export async function registerUrlInteraction(url: string, interaction: URLInteraction) { + if (!isValidUrl(url)) { + return; + } + + const interactions = getUrlInteractions(); + const idx = interactions.findIndex(item => item.url === url); + if (idx !== -1) { + if (!interactions[idx].interactions.includes(interaction)) { + interactions[idx].interactions.push(interaction); + } + } else { + interactions.push({ + interactions: [interaction], + url, + lastUpdated: Date.now(), + }); + } + + await Storage.put(SettingsKey.urlInteractions, interactions); +} + +export function getUrlInteractionsForUrl(url: string): Array { + if (!isValidUrl(url)) { + return []; + } + + const interactions = getUrlInteractions(); + return interactions.find(item => item.url === url)?.interactions ?? []; +} + +export async function clearAllUrlInteractions() { + await Storage.put(SettingsKey.urlInteractions, []); +} + +export async function removeUrlInteractionHistory(url: string) { + const interactions = getUrlInteractions(); + const idx = interactions.findIndex(item => item.url === url); + if (idx !== -1) { + interactions.splice(idx); + } + + await Storage.put(SettingsKey.urlInteractions, interactions); +} + +export function urlInteractionToString(interaction: URLInteraction) { + switch (interaction) { + case URLInteraction.OPEN: + return tr('open'); + case URLInteraction.COPY: + return tr('copy'); + case URLInteraction.TRUST: + // TODO: use localized string once it exists + return 'Trust'; + default: + return tr('unknown'); + } +} diff --git a/yarn.lock b/yarn.lock index 5388305ed8..ec158db2c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3865,9 +3865,9 @@ foreground-child@^3.1.0: signal-exit "^4.0.1" form-data@^4.0.0, form-data@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" - integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" @@ -5067,9 +5067,9 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -"libsession_util_nodejs@https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.1/libsession_util_nodejs-v0.6.1.tar.gz": - version "0.6.1" - resolved "https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.1/libsession_util_nodejs-v0.6.1.tar.gz#472903f8d4a2aa8501fe5f69f3540b20f9880a14" +"libsession_util_nodejs@https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.2/libsession_util_nodejs-v0.6.2.tar.gz": + version "0.6.2" + resolved "https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.2/libsession_util_nodejs-v0.6.2.tar.gz#875a144fcf495aa13fc6cc80950702de970c467e" dependencies: cmake-js "7.3.1" node-addon-api "^8.3.1"