diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index aefcfa45..e676ad36 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -28,12 +28,12 @@ } }, "forwardPorts": [ - 2137, - 2053, - 2227, - 4298, + 2169, + 2232, + 2160, + 4097, 5000, - 5174 + 5215 ], "features": { "ghcr.io/devcontainers/features/python": {}, diff --git a/.editorconfig b/.editorconfig index f309fe28..cbac95d3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,7 @@ [*] indent_style = space indent_size = 4 +spelling_exclusion_path = vs-spell.dic # Code files [*.{cs,csx,vb,vbx}] insert_final_newline = true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bfc2245d..ff4cd9c0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -60,7 +60,7 @@ Carefully analyze the user's prompt. Identify the core objectives, whether it is Before writing code, investigate thoroughly. * If the user provides a **URL**, you **MUST** use the `fetch` tool to retrieve its content. * If the user provides a **git commit id/hash**, you **MUST** run the `git --no-pager show ` command to retrieve its details. -* If the user taked about current changes in the codebase, you **MUST** run the `git --no-pager diff` and `git --no-pager diff --staged` commands to see the differences. +* If the user talked about current changes in the codebase, you **MUST** run the `git --no-pager diff` and `git --no-pager diff --staged` commands to see the differences. * For UI-related tasks, you **MUST** first ask `DeepWiki`: *"What features does BitPlatform offer to help me complete this task? [USER'S ORIGINAL REQUEST]"* * For anything related to `Bit.BlazorUI`, `bit Bswup`, `bit Butil`, `bit Besql`, or the bit project template, you **MUST** use the `DeepWiki_ask_question` tool with repository `bitfoundation/bitplatform` to find relevant information. * For mapper/mapping entity/dto related tasks, you **MUST** use the `DeepWiki_ask_question` tool with repository `riok/mapperly` to find correct implementation and usage patterns focusing on its static classes and extension methods approach. diff --git a/.github/prompts/scaffold.prompt.md b/.github/prompts/scaffold.prompt.md index 9a65a12a..eeaa3b72 100644 --- a/.github/prompts/scaffold.prompt.md +++ b/.github/prompts/scaffold.prompt.md @@ -1,7 +1,6 @@ -```prompt # Scaffold Complete Entity with Full CRUD -You are an expert at scaffolding complete entity implementations for the BitPlatform bit.templateplayground project. +You are an expert at scaffolding complete entity implementations for the project. ## Instructions diff --git a/.github/workflows/cd-template.yml b/.github/workflows/cd-template.yml index 76a3aca6..0075a6eb 100644 --- a/.github/workflows/cd-template.yml +++ b/.github/workflows/cd-template.yml @@ -7,6 +7,9 @@ on: required: true type: string +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + permissions: contents: read @@ -19,10 +22,10 @@ jobs: steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json @@ -42,6 +45,7 @@ jobs: env: WebAppRender.BlazorMode: 'BlazorWebAssembly' ServerAddress: ${{ vars.SERVER_ADDRESS }} + AdsPushVapid.PublicKey: ${{ secrets.PUBLIC_VAPIDKEY }} - name: Install wasm run: cd src && dotnet workload install wasm-tools @@ -71,7 +75,7 @@ jobs: steps: - name: Retrieve server bundle - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: server-bundle @@ -97,10 +101,10 @@ jobs: steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json @@ -144,10 +148,10 @@ jobs: steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json @@ -202,10 +206,10 @@ jobs: steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35411d92..7ca9a367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: workflow_dispatch: pull_request: +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + jobs: build_blazor_server: @@ -15,10 +18,10 @@ jobs: steps: - name: Checkout source code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json diff --git a/Bit.ResxTranslator.json b/Bit.ResxTranslator.json index c7c700ae..40109c4c 100644 --- a/Bit.ResxTranslator.json +++ b/Bit.ResxTranslator.json @@ -8,6 +8,10 @@ "ResxPaths": [ "/src/**/*.resx" ], + "ChatOptions": { + "Temperature": "0" + }, + "OpenAI": { "Model": "gpt-4.1-mini", "Endpoint": "https://models.inference.ai.azure.com", diff --git a/Bit.TemplatePlayground.sln b/Bit.TemplatePlayground.sln index 687da649..4f3fbff4 100644 --- a/Bit.TemplatePlayground.sln +++ b/Bit.TemplatePlayground.sln @@ -19,6 +19,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".SolutionItems", ".Solution src\Directory.Build.props = src\Directory.Build.props src\Directory.Packages.props = src\Directory.Packages.props global.json = global.json + vs-spell.dic = vs-spell.dic .vscode\mcp.json=.vscode\mcp.json README.md = README.md EndProjectSection diff --git a/Bit.TemplatePlayground.slnx b/Bit.TemplatePlayground.slnx index 4d775e3a..5c2b895e 100644 --- a/Bit.TemplatePlayground.slnx +++ b/Bit.TemplatePlayground.slnx @@ -10,6 +10,7 @@ + @@ -17,13 +18,6 @@ - - - - - - - @@ -31,21 +25,17 @@ - - - - diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Bit.TemplatePlayground.Client.Core.csproj b/src/Client/Bit.TemplatePlayground.Client.Core/Bit.TemplatePlayground.Client.Core.csproj index ed9c3af6..b4b0d43a 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Bit.TemplatePlayground.Client.Core.csproj +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Bit.TemplatePlayground.Client.Core.csproj @@ -15,11 +15,10 @@ - - + diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/AppClientCoordinator.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Components/AppClientCoordinator.cs index 45e65bc7..0c7a967d 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/AppClientCoordinator.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/AppClientCoordinator.cs @@ -24,6 +24,7 @@ public partial class AppClientCoordinator : AppComponentBase [AutoInject] private ILogger navigatorLogger = default!; [AutoInject] private ILogger logger = default!; [AutoInject] private IBitDeviceCoordinator bitDeviceCoordinator = default!; + [AutoInject] private IPushNotificationService pushNotificationService = default!; private Action? unsubscribe; @@ -113,6 +114,7 @@ public async Task PropagateAuthState(bool firstRun, Task ta await EnsureSignalRStarted(); + await pushNotificationService.Subscribe(CurrentCancellationToken); if (isAuthenticated) { @@ -225,9 +227,16 @@ private async Task HubConnectionStateChange(Exception? exception) { logger.LogWarning(exception, "SignalR connection lost."); - if (exception is HubException && exception.Message.EndsWith(nameof(AppStrings.UnauthorizedException))) + if (exception is HubException) { - await AuthManager.RefreshToken(requestedBy: nameof(HubException)); + if (exception.Message.EndsWith(nameof(AppStrings.UnauthorizedException))) + { + await AuthManager.RefreshToken(requestedBy: nameof(HubException)); + } + else if (exception.Message.EndsWith(nameof(AppStrings.ForceUpdateTitle))) + { + PubSubService.Publish(ClientPubSubMessages.FORCE_UPDATE); + } } } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor index 491924c1..d75e5c5d 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor @@ -7,19 +7,19 @@ + IconUrl="_content/Bit.TemplatePlayground.Client.Core/images/icons/ai-chat-icon-64.webp" /> + OnDismiss="WrapHandled(HandleOnDismissPanel)" + Classes="@(new() { Container = "panel-cnt" })">
@Localizer[nameof(AppStrings.AiChatPanelTitle)] @@ -99,6 +99,24 @@ } + @if (followUpSuggestions.Any()) + { + + + @foreach (var suggestion in followUpSuggestions) + { + + @suggestion + + } + + + } + + Placeholder="@(Localizer[nameof(AppStrings.AiChatPanelPlaceholder)])" /> ? channel; private AiChatMessage? lastAssistantMessage; private List chatMessages = []; // TODO: Persist these values in client-side storage to retain them across app restarts. + private List followUpSuggestions = []; protected override Task OnInitAsync() @@ -50,7 +51,9 @@ private async Task HubConnection_Reconnected(string? _) private async Task SendPromptMessage(string message) { + followUpSuggestions = []; userInput = message; + StateHasChanged(); await SendMessage(); } @@ -86,6 +89,7 @@ private void SetDefaultValues() { isLoading = false; responseCounter = 0; + followUpSuggestions = []; lastAssistantMessage = new() { Role = AiChatMessageRole.Assistant }; chatMessages = [ new() @@ -125,25 +129,32 @@ private async Task StartChannel() { int expectedResponsesCount = chatMessages.Count(c => c.Role is AiChatMessageRole.User); - if (response is SharedChatProcessMessages.MESSAGE_RPOCESS_SUCESS) + if (response.Contains(nameof(AiChatFollowUpList.FollowUpSuggestions))) { - responseCounter++; - isLoading = false; + followUpSuggestions = JsonSerializer.Deserialize(response)?.FollowUpSuggestions ?? []; } - else if (response is SharedChatProcessMessages.MESSAGE_RPOCESS_ERROR) + else { - responseCounter++; - if (responseCounter == expectedResponsesCount) + if (response is SharedChatProcessMessages.MESSAGE_RPOCESS_SUCESS) { - isLoading = false; // Hide loading only if this is an error for the last user's message. + responseCounter++; + isLoading = false; } - chatMessages[responseCounter * 2].Successful = false; - } - else - { - if ((responseCounter + 1) == expectedResponsesCount) + else if (response is SharedChatProcessMessages.MESSAGE_RPOCESS_ERROR) + { + responseCounter++; + if (responseCounter == expectedResponsesCount) + { + isLoading = false; // Hide loading only if this is an error for the last user's message. + } + chatMessages[responseCounter * 2].Successful = false; + } + else { - lastAssistantMessage!.Content += response; + if ((responseCounter + 1) == expectedResponsesCount) + { + lastAssistantMessage!.Content += response; + } } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor.scss b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor.scss index 76f42389..1bdc6168 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor.scss +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppAiChatPanel.razor.scss @@ -2,31 +2,57 @@ @import '../../Styles/abstracts/_bit-css-variables.scss'; ::deep { - - .scr-container { - padding: 0.5rem; - - img { - padding: 0.5rem; - max-width: 256px; - } - } - .open-panel-button { + padding: 0; left: unset; right: unset; inset-inline-end: 2rem; + &:hover, &:active { + background-color: transparent; + } + @include lt-md { bottom: 4rem; inset-inline-end: 1rem; } img { + width: 2.5rem; + height: 2.5rem; pointer-events: none; } } + .panel-cnt { + top: $bit-env-inset-top; + bottom: $bit-env-inset-bottom; + height: $bit-env-height-available; + } + + .scr-container { + padding-inline-end: 0.5rem; + + img { + padding: 0.5rem; + max-width: 256px; + } + + pre { + margin: 0; + overflow: auto; + scrollbar-width: thin; + + code { + display: block; + width: fit-content; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + background: $bit-color-background-secondary; + } + } + } + .body { width: 600px; max-width: 100%; diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppSnackBar.razor b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppSnackBar.razor index 4e87fa83..de0e6885 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppSnackBar.razor +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Layout/AppSnackBar.razor @@ -1,6 +1,6 @@ @inherits AppComponentBase - \ No newline at end of file + Styles="@(new() { OkButton = "width:100%", CancelButton = "width:100%" })" /> \ No newline at end of file diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor index 34c6c00f..afd22acf 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor @@ -9,10 +9,17 @@
- - @Localizer[nameof(AppStrings.AddCategory)] - + + + @Localizer[nameof(AppStrings.AddCategory)] + + @if (isLoading) + { + + } +
? dataGrid; private string categoryNameFilter = string.Empty; + + private BitDataGrid? dataGrid; private BitDataGridItemsProvider categoriesProvider = default!; private BitDataGridPaginationState pagination = new() { ItemsPerPage = 10 }; + + [AutoInject] ICategoryController categoryController = default!; + + private string CategoryNameFilter { get => categoryNameFilter; @@ -26,6 +29,7 @@ private string CategoryNameFilter } } + protected override async Task OnInitAsync() { await base.OnInitAsync(); @@ -33,15 +37,17 @@ protected override async Task OnInitAsync() PrepareGridDataProvider(); } + private void PrepareGridDataProvider() { categoriesProvider = async req => { isLoading = true; + StateHasChanged(); try { - var odataQ = new ODataQuery + var query = new ODataQuery { Top = req.Count ?? 10, Skip = req.StartIndex, @@ -50,11 +56,12 @@ private void PrepareGridDataProvider() if (string.IsNullOrEmpty(CategoryNameFilter) is false) { - odataQ.Filter = $"contains(tolower({nameof(CategoryDto.Name)}),'{CategoryNameFilter.ToLower()}')"; + query.Filter = $"contains(tolower({nameof(CategoryDto.Name)}),'{CategoryNameFilter.ToLower()}')"; } - - var data = await categoryController.WithQuery(odataQ.ToString()).GetCategories(req.CancellationToken); - + + var data = await categoryController.WithQuery(query.ToString()) + .GetCategories(req.CancellationToken); + return BitDataGridItemsProviderResult.From(data!.Items!, (int)data!.TotalCount); } catch (Exception exp) @@ -65,7 +72,6 @@ private void PrepareGridDataProvider() finally { isLoading = false; - StateHasChanged(); } }; diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor.scss b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor.scss index 636fb618..82a51aa2 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor.scss +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Categories/CategoriesPage.razor.scss @@ -7,7 +7,7 @@ section { .grid-container { overflow: auto; - height: calc(#{$bit-env-height-available} - 14rem); + height: calc(#{$bit-env-height-available} - 12.1rem); @include lt-md { height: calc(#{$bit-env-height-available} - 17rem); diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/Components/AppleIcon.razor b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/Components/AppleIcon.razor index 1e8979e8..ff787396 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/Components/AppleIcon.razor +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/Components/AppleIcon.razor @@ -3,6 +3,6 @@ - \ No newline at end of file diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/ForgotPasswordPage.razor.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/ForgotPasswordPage.razor.cs index 40e2d3ce..29332911 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/ForgotPasswordPage.razor.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/ForgotPasswordPage.razor.cs @@ -44,7 +44,7 @@ private async Task Submit() { await identityController.SendResetPasswordToken(model, CurrentCancellationToken); } - catch (TooManyRequestsExceptions e) + catch (TooManyRequestsException e) { SnackBarService.Error(e.Message); // Let's go to the reset password page anyway. diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs index 2242fd93..ef1c7d91 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs @@ -325,7 +325,7 @@ private async Task SendOtp(bool resend) isOtpSent = true; } - catch (TooManyRequestsExceptions e) + catch (TooManyRequestsException e) { isOtpSent = true; SnackBarService.Error(e.Message); diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignUp/SignUpPage.razor.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignUp/SignUpPage.razor.cs index 674d7d1e..df5ec39c 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignUp/SignUpPage.razor.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Identity/SignUp/SignUpPage.razor.cs @@ -42,7 +42,7 @@ private async Task DoSignUp() { NavigateToConfirmPage(); } - catch (TooManyRequestsExceptions e) + catch (TooManyRequestsException e) { SnackBarService.Error(e.Message); NavigateToConfirmPage(); diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor index 00cf8774..92eccd34 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor @@ -11,11 +11,20 @@
- - - @Localizer[nameof(AppStrings.AddProduct)] - + + + + @Localizer[nameof(AppStrings.AddProduct)] + + @if (isLoading) + { + + } + + Href="@($"{PageUrls.AddOrEditProduct}/{product.Id}")" /> { isLoading = true; + StateHasChanged(); try { - var odataQ = new ODataQuery + var query = new ODataQuery { Top = req.Count ?? 10, Skip = req.StartIndex, @@ -66,18 +68,18 @@ private void PrepareGridDataProvider() if (string.IsNullOrEmpty(ProductNameFilter) is false) { - odataQ.Filter = $"contains(tolower({nameof(ProductDto.Name)}),'{ProductNameFilter.ToLower()}')"; + query.Filter = $"contains(tolower({nameof(ProductDto.Name)}),'{ProductNameFilter.ToLower()}')"; } if (string.IsNullOrEmpty(CategoryNameFilter) is false) { - odataQ.AndFilter = $"contains(tolower({nameof(ProductDto.CategoryName)}),'{CategoryNameFilter.ToLower()}')"; + query.AndFilter = $"contains(tolower({nameof(ProductDto.CategoryName)}),'{CategoryNameFilter.ToLower()}')"; } - var queriedRequest = productController.WithQuery(odataQ.ToString()); + var queriedRequest = productController.WithQuery(query.ToString()); var data = await (string.IsNullOrWhiteSpace(searchQuery) - ? queriedRequest.GetProducts(req.CancellationToken) - : queriedRequest.GetProductsBySearchQuery(searchQuery, req.CancellationToken)); + ? queriedRequest.GetProducts(req.CancellationToken) + : queriedRequest.GetProductsBySearchQuery(searchQuery, req.CancellationToken)); return BitDataGridItemsProviderResult.From(data!.Items!, (int)data!.TotalCount); } @@ -89,7 +91,6 @@ private void PrepareGridDataProvider() finally { isLoading = false; - StateHasChanged(); } }; diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor.scss b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor.scss index ed202fbb..208d3a0f 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor.scss +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Products/ProductsPage.razor.scss @@ -7,7 +7,7 @@ section { .grid-container { overflow: auto; - height: calc(#{$bit-env-height-available} - 14rem); + height: calc(#{$bit-env-height-available} - 12.1rem); @include lt-md { height: calc(#{$bit-env-height-available} - 17rem); diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Settings/SessionsSection.razor.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Settings/SessionsSection.razor.cs index b2a157bc..70df2380 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Settings/SessionsSection.razor.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/Settings/SessionsSection.razor.cs @@ -16,7 +16,7 @@ public partial class SessionsSection private UserSessionDto[] otherSessions = []; [AutoInject] private IUserController userController = default!; - [AutoInject] private Notification notification = default!; + [AutoInject] private IPushNotificationService pushNotificationService = default!; protected override async Task OnInitAsync() @@ -121,9 +121,10 @@ private async Task ToggleNotification(UserSessionDto userSession) // User is going to allow notifications so it's an opportune time to request permission. // The permission might have already been requested (if userSession.NotificationStatus is UserSessionNotificationStatus.Muted), but there's no harm in asking for permission again. - if (await notification.IsSupported()) + if (AppPlatform.IsWindows is false) { - await notification.RequestPermission(); + await pushNotificationService.RequestPermission(CurrentCancellationToken); + await pushNotificationService.Subscribe(CurrentCancellationToken); } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor index c12dd91b..653e81a3 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor @@ -11,19 +11,28 @@ { } - else if (systemPrompt is not null) + else if (systemPrompts is not null) { - - - @Localizer[nameof(AppStrings.Save)] - - + - - - - - - + @foreach (var systemPrompt in systemPrompts) + { + + + + @Localizer[nameof(AppStrings.Save)] + + + + + + + + + + + } + + }
\ No newline at end of file diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor.cs index 34272c87..4f8351d9 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/Pages/SystemPromptsPage.razor.cs @@ -8,7 +8,7 @@ public partial class SystemPromptsPage { [AutoInject] private IChatbotController chatbotController = default!; - private SystemPromptDto? systemPrompt; + private List? systemPrompts; private bool isLoading = true; @@ -18,7 +18,9 @@ protected override async Task OnAfterFirstRenderAsync() try { - systemPrompt = await chatbotController.GetSystemPrompt(PromptKind.Support, CurrentCancellationToken); + systemPrompts = await chatbotController + .WithQuery($"$orderby={nameof(SystemPromptDto.PromptKind)}") + .GetSystemPrompts(CurrentCancellationToken); } finally { @@ -27,7 +29,7 @@ protected override async Task OnAfterFirstRenderAsync() } } - private async Task SaveChanges() + private async Task SaveChanges(SystemPromptDto systemPrompt) { if (await AuthManager.TryEnterElevatedAccessMode(CurrentCancellationToken)) { diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Components/WebInteropApp.razor b/src/Client/Bit.TemplatePlayground.Client.Core/Components/WebInteropApp.razor index 0fab15c8..b38ed3aa 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Components/WebInteropApp.razor +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Components/WebInteropApp.razor @@ -10,7 +10,11 @@ Additionally, in a Web Browser, if Social Sign-in is performed in a Popup or a n state and avoid restarting Blazor upon returning from Social Sign-in, the Popup should redirect back to WebInteropApp after authentication. Using app.js, it can notify the main Window via window.opener.postMessage({ key: 'PUBLISH_MESSAGE', ... }), allowing the main Window to resume its operations. + +When publishing Client.Web (BlazorWebAssembly Standalone) and Server.Api, there won't be any WebInteropAppEndpoint, +in this case, uncomment the Route attribute below to make this component accessible via /web-interop-app route. *@ +@* @attribute [Route("web-interop-app")] *@ @code { [Inject] public IStringLocalizer Localizer { get; set; } = default!; diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IClientCoreServiceCollectionExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IClientCoreServiceCollectionExtensions.cs index 7738c239..bca62fd4 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IClientCoreServiceCollectionExtensions.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IClientCoreServiceCollectionExtensions.cs @@ -116,6 +116,11 @@ public static IServiceCollection AddClientCoreProjectServices(this IServiceColle .WithAutomaticReconnect(sp.GetRequiredService()) .WithUrl(new Uri(absoluteServerAddressProvider.GetAddress(), "app-hub"), options => { + var telemetryContext = sp.GetRequiredService(); + + options.Headers.Add("X-App-Version", telemetryContext.AppVersion!); + options.Headers.Add("X-App-Platform", AppPlatform.Type.ToString()); + options.SkipNegotiation = false; // Required for Azure SignalR. options.Transports = HttpTransportType.WebSockets; // Avoid enabling long polling or Server-Sent Events. Focus on resolving the issue with WebSockets instead. diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IJSRuntimeExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IJSRuntimeExtensions.cs index 6ef3da00..b8b74b35 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IJSRuntimeExtensions.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Extensions/IJSRuntimeExtensions.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; namespace Microsoft.JSInterop; @@ -19,6 +20,10 @@ public static ValueTask GoogleRecaptchaReset(this IJSRuntime jsRuntime) return jsRuntime.InvokeAsync("grecaptcha.reset"); } + public static async ValueTask GetPushNotificationSubscription(this IJSRuntime jsRuntime, string vapidPublicKey) + { + return await jsRuntime.InvokeAsync("App.getPushNotificationSubscription", vapidPublicKey); + } /// /// The return value would be false during pre-rendering diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Scripts/app.ts b/src/Client/Bit.TemplatePlayground.Client.Core/Scripts/app.ts index 041ac03a..a1856731 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Scripts/app.ts +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Scripts/app.ts @@ -43,6 +43,34 @@ class App { } } + public static async getPushNotificationSubscription(vapidPublicKey: string) { + const registration = await navigator.serviceWorker.ready; + if (!registration) return null; + + const pushManager = registration.pushManager; + if (!pushManager) return null; + + let subscription = await pushManager.getSubscription(); + + if (!subscription) { + subscription = await pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidPublicKey + }); + } + + const pushChannel = subscription.toJSON(); + const p256dh = pushChannel.keys!['p256dh']; + const auth = pushChannel.keys!['auth']; + + return { + deviceId: `${p256dh}-${auth}`, + platform: 'browser', + p256dh: p256dh, + auth: auth, + endpoint: pushChannel.endpoint + }; + }; /* Checks for and applies updates if available. Called by `WebAppUpdateService.cs` when the user clicks the app version in `AppShell.razor` diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Services/AuthManager.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Services/AuthManager.cs index 79d04cb9..9a1c123e 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Services/AuthManager.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Services/AuthManager.cs @@ -181,7 +181,7 @@ public async Task TryEnterElevatedAccessMode(CancellationToken cancellatio { await userController.SendElevatedAccessToken(cancellationToken); } - catch (TooManyRequestsExceptions exp) + catch (TooManyRequestsException exp) { exceptionHandler.Handle(exp, displayKind: ExceptionDisplayKind.NonInterrupting); // Let's show prompt anyway. } diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Services/Contracts/IPushNotificationService.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Services/Contracts/IPushNotificationService.cs new file mode 100644 index 00000000..29872340 --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Services/Contracts/IPushNotificationService.cs @@ -0,0 +1,15 @@ +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +namespace Bit.TemplatePlayground.Client.Core.Services.Contracts; + +public interface IPushNotificationService +{ + string Token { get; set; } + /// + /// Supported by the OS/Platform and allowed by the user. + /// + Task IsAvailable(CancellationToken cancellationToken); + Task RequestPermission(CancellationToken cancellationToken); + Task GetSubscription(CancellationToken cancellationToken); + Task Subscribe(CancellationToken cancellationToken); +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs index e52ccb8d..097b3a53 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/ExceptionDelegatingHandler.cs @@ -26,6 +26,7 @@ protected override async Task SendAsync(HttpRequestMessage if (response.Headers.TryGetValues("Request-Id", out var requestId)) { requestIdValue = requestId.First(); + logScopeData["RequestId"] = requestIdValue; } serverCommunicationSuccess = true; @@ -85,8 +86,10 @@ private bool IsServerConnectionException(Exception exp) return (exp is TimeoutException) || (exp is WebException webExp && webExp.WithData("Status", webExp.Status).Status is WebExceptionStatus.ConnectFailure) || (exp.InnerException is not null && IsServerConnectionException(exp.InnerException)) + || (exp is HttpIOException httpIOExp && httpIOExp.WithData("HttpRequestError", httpIOExp.HttpRequestError).HttpRequestError is not HttpRequestError.UserAuthenticationError) || (exp is AggregateException aggExp && aggExp.InnerExceptions.Any(IsServerConnectionException)) || (exp is SocketException sockExp && sockExp.WithData("SocketErrorCode", sockExp.SocketErrorCode).SocketErrorCode is SocketError.HostNotFound or SocketError.HostUnreachable or SocketError.HostDown or SocketError.TimedOut) - || (exp is HttpRequestException reqExp && reqExp.WithData("StatusCode", reqExp.StatusCode).StatusCode is HttpStatusCode.BadGateway or HttpStatusCode.GatewayTimeout or HttpStatusCode.ServiceUnavailable or HttpStatusCode.RequestTimeout); + || (exp is HttpRequestException reqExp && reqExp.WithData("StatusCode", reqExp.StatusCode).StatusCode is HttpStatusCode.BadGateway or HttpStatusCode.GatewayTimeout or HttpStatusCode.ServiceUnavailable or HttpStatusCode.RequestTimeout) + || (exp is HttpProtocolException proExp && proExp.WithData("HttpRequestError", proExp.HttpRequestError).WithData("ErrorCode", proExp.ErrorCode).HttpRequestError is not HttpRequestError.UserAuthenticationError); } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/RequestHeadersDelegatingHandler.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/RequestHeadersDelegatingHandler.cs index 6c282618..814d2ccf 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/RequestHeadersDelegatingHandler.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Services/HttpMessageHandlers/RequestHeadersDelegatingHandler.cs @@ -25,8 +25,16 @@ protected override async Task SendAsync(HttpRequestMessage request.Headers.AcceptLanguage.Add(new StringWithQualityHeaderValue(CultureInfo.CurrentUICulture.Name)); } - request.Headers.Add("X-App-Version", telemetryContext.AppVersion); - request.Headers.Add("X-App-Platform", AppPlatform.Type.ToString()); + var isInternalRequest = request.HasExternalApiAttribute() is false; + if (isInternalRequest) + { + request.Headers.Add("X-App-Version", telemetryContext.AppVersion); + request.Headers.Add("X-App-Platform", AppPlatform.Type.ToString()); + } + else + { + request.Headers.Remove("X-Origin"); // It gets added by default in Program.Services.cs of Client projects and it might be rejected by some external APIs due to CORS limitations. + } return await base.SendAsync(request, cancellationToken); } diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/Services/PushNotificationServiceBase.cs b/src/Client/Bit.TemplatePlayground.Client.Core/Services/PushNotificationServiceBase.cs new file mode 100644 index 00000000..5de6e630 --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Core/Services/PushNotificationServiceBase.cs @@ -0,0 +1,31 @@ +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; +using Bit.TemplatePlayground.Shared.Controllers.PushNotification; + +namespace Bit.TemplatePlayground.Client.Core.Services; + +public abstract partial class PushNotificationServiceBase : IPushNotificationService +{ + [AutoInject] protected ILogger Logger = default!; + [AutoInject] protected IPushNotificationController pushNotificationController = default!; + + public virtual string Token { get; set; } + public virtual Task IsAvailable(CancellationToken cancellationToken) => Task.FromResult(false); + public abstract Task GetSubscription(CancellationToken cancellationToken); + public abstract Task RequestPermission(CancellationToken cancellationToken); + + public async Task Subscribe(CancellationToken cancellationToken) + { + if (await IsAvailable(cancellationToken) is false) + { + Logger.LogWarning("Notifications are not supported/allowed on this platform/device."); + return; + } + + var subscription = await GetSubscription(cancellationToken); + + if (subscription is null) + return; + + await pushNotificationController.Subscribe(subscription, cancellationToken); + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/package-lock.json b/src/Client/Bit.TemplatePlayground.Client.Core/package-lock.json index b48c321f..9783db44 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/package-lock.json +++ b/src/Client/Bit.TemplatePlayground.Client.Core/package-lock.json @@ -5,15 +5,15 @@ "packages": { "": { "devDependencies": { - "esbuild": "0.25.8", - "sass": "1.90.0", + "esbuild": "0.25.9", + "sass": "1.92.1", "typescript": "5.9.2" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "cpu": [ "ppc64" ], @@ -28,9 +28,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "cpu": [ "arm" ], @@ -45,9 +45,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "cpu": [ "arm64" ], @@ -62,9 +62,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "cpu": [ "x64" ], @@ -79,9 +79,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "cpu": [ "arm64" ], @@ -96,9 +96,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "cpu": [ "x64" ], @@ -113,9 +113,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "cpu": [ "arm64" ], @@ -130,9 +130,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "cpu": [ "x64" ], @@ -147,9 +147,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "cpu": [ "arm" ], @@ -164,9 +164,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "cpu": [ "arm64" ], @@ -181,9 +181,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "cpu": [ "ia32" ], @@ -198,9 +198,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "cpu": [ "loong64" ], @@ -215,9 +215,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "cpu": [ "mips64el" ], @@ -232,9 +232,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "cpu": [ "ppc64" ], @@ -249,9 +249,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "cpu": [ "riscv64" ], @@ -266,9 +266,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "cpu": [ "s390x" ], @@ -283,9 +283,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "cpu": [ "x64" ], @@ -300,9 +300,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "cpu": [ "arm64" ], @@ -317,9 +317,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "cpu": [ "x64" ], @@ -334,9 +334,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "cpu": [ "arm64" ], @@ -351,9 +351,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "cpu": [ "x64" ], @@ -368,9 +368,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "cpu": [ "arm64" ], @@ -385,9 +385,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "cpu": [ "x64" ], @@ -402,9 +402,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "cpu": [ "arm64" ], @@ -419,9 +419,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "cpu": [ "ia32" ], @@ -436,9 +436,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "cpu": [ "x64" ], @@ -784,9 +784,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -797,32 +797,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" } }, "node_modules/fill-range": { @@ -934,9 +934,9 @@ } }, "node_modules/sass": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz", - "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", + "version": "1.92.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.92.1.tgz", + "integrity": "sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/package.json b/src/Client/Bit.TemplatePlayground.Client.Core/package.json index 411fef69..6eaa7ea2 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Core/package.json +++ b/src/Client/Bit.TemplatePlayground.Client.Core/package.json @@ -1,7 +1,7 @@ { "devDependencies": { - "esbuild": "0.25.8", - "sass": "1.90.0", + "esbuild": "0.25.9", + "sass": "1.92.1", "typescript": "5.9.2" } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-chat-icon-64.webp b/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-chat-icon-64.webp new file mode 100644 index 00000000..d7b6a247 Binary files /dev/null and b/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-chat-icon-64.webp differ diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-icon-dark.png b/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-icon-dark.png deleted file mode 100644 index 123721f4..00000000 Binary files a/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-icon-dark.png and /dev/null differ diff --git a/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-icon-light.png b/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-icon-light.png deleted file mode 100644 index e75904db..00000000 Binary files a/src/Client/Bit.TemplatePlayground.Client.Core/wwwroot/images/icons/ai-icon-light.png and /dev/null differ diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Bit.TemplatePlayground.Client.Maui.csproj b/src/Client/Bit.TemplatePlayground.Client.Maui/Bit.TemplatePlayground.Client.Maui.csproj index f390bec8..df933371 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Bit.TemplatePlayground.Client.Maui.csproj +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Bit.TemplatePlayground.Client.Maui.csproj @@ -94,6 +94,9 @@ + + + @@ -147,6 +150,7 @@ + @@ -155,6 +159,7 @@ + diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/MauiProgram.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/MauiProgram.cs index b53ded2d..605dac64 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/MauiProgram.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/MauiProgram.cs @@ -1,5 +1,6 @@ using Microsoft.Maui.Platform; using Microsoft.Maui.LifecycleEvents; +using Plugin.LocalNotification; using Bit.TemplatePlayground.Client.Core.Styles; using Bit.TemplatePlayground.Client.Maui.Services; using Maui.AppStores; @@ -35,6 +36,10 @@ public static MauiApp CreateMauiApp() .UseAppStoreInfo() .Configuration.AddClientConfigurations(clientEntryAssemblyName: "Bit.TemplatePlayground.Client.Maui"); + if (AppPlatform.IsWindows is false) + { + builder.UseLocalNotification(); + } builder.ConfigureServices(); diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Extensions/IAndroidServiceCollectionExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Extensions/IAndroidServiceCollectionExtensions.cs index 18be6b97..b9fa4a3b 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Extensions/IAndroidServiceCollectionExtensions.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Extensions/IAndroidServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Bit.TemplatePlayground.Client.Maui.Platforms.Android.Services; namespace Microsoft.Extensions.DependencyInjection; @@ -7,6 +8,7 @@ public static IServiceCollection AddClientMauiProjectAndroidServices(this IServi { // Services being registered here can get injected in Maui/Android. + services.AddSingleton(); return services; } diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainActivity.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainActivity.cs index ec26328a..2625809d 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainActivity.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainActivity.cs @@ -3,6 +3,8 @@ using Android.App; using Android.Content; using Android.Content.PM; +using Android.Gms.Tasks; +using Plugin.LocalNotification; using Bit.TemplatePlayground.Client.Core.Components; namespace Bit.TemplatePlayground.Client.Maui.Platforms.Android; @@ -26,7 +28,9 @@ namespace Bit.TemplatePlayground.Client.Maui.Platforms.Android; [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTask, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] public partial class MainActivity : MauiAppCompatActivity + , IOnSuccessListener { + private IPushNotificationService PushNotificationService => IPlatformApplication.Current!.Services.GetRequiredService(); protected override void OnCreate(Bundle? savedInstanceState) { @@ -41,8 +45,45 @@ protected override void OnCreate(Bundle? savedInstanceState) _ = Routes.OpenUniversalLink(new URL(url).File ?? PageUrls.Home); } + HandlePushNotificationTap(Intent); // Handling push notification taps when the app was closed. + PushNotificationService.IsAvailable(default).ContinueWith(task => + { + if (task.Result) + { + Services.AndroidPushNotificationService.Configure(); + } + }); } + private static void HandlePushNotificationTap(Intent? intent) + { + if (intent is null) + return; + + var dataString = intent.GetStringExtra(LocalNotificationCenter.ReturnRequest); + string? pageUrl = null; + if (string.IsNullOrEmpty(dataString) is false) + { + var request = JsonSerializer.Deserialize(dataString, options: new() + { + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals + }); + if (request?.ReturningData is not null) + { + var returningData = JsonSerializer.Deserialize>(request.ReturningData); + if (returningData?.ContainsKey("pageUrl") is true) + { + pageUrl = returningData["pageUrl"]?.ToString(); // The time that the notification received, the app was open. (See PushNotificationFirebaseMessagingService's OnMessageReceived) + } + } + } + + pageUrl ??= intent?.Extras?.Get("pageUrl")?.ToString(); + if (string.IsNullOrEmpty(pageUrl) is false) + { + _ = Routes.OpenUniversalLink(pageUrl ?? PageUrls.Home); // The time that the notification received, the app was closed. + } + } protected override void OnNewIntent(Intent? intent) { @@ -55,6 +96,11 @@ protected override void OnNewIntent(Intent? intent) _ = Routes.OpenUniversalLink(new URL(url).File ?? PageUrls.Home); } + HandlePushNotificationTap(intent); // Handling push notification taps when the app is running. } + public void OnSuccess(Java.Lang.Object? result) + { + PushNotificationService.Token = result!.ToString(); + } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainApplication.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainApplication.cs index 99e6775a..0c71b680 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainApplication.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/MainApplication.cs @@ -4,6 +4,11 @@ [assembly: UsesPermission(Android.Manifest.Permission.Internet)] [assembly: UsesPermission(Android.Manifest.Permission.AccessNetworkState)] +// https://github.com/thudugala/Plugin.LocalNotification/wiki/1.-Usage-10.0.0--.Net-MAUI#android-specific-setup +[assembly: UsesPermission(Android.Manifest.Permission.PostNotifications)] +[assembly: UsesPermission(Android.Manifest.Permission.Vibrate)] +[assembly: UsesPermission(Android.Manifest.Permission.WakeLock)] +[assembly: UsesPermission(Android.Manifest.Permission.ReceiveBootCompleted)] namespace Bit.TemplatePlayground.Client.Maui.Platforms.Android; diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Services/AndroidPushNotificationService.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Services/AndroidPushNotificationService.cs new file mode 100644 index 00000000..9451ca95 --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Services/AndroidPushNotificationService.cs @@ -0,0 +1,73 @@ +using Firebase.Messaging; +using Plugin.LocalNotification; +using Microsoft.Extensions.Logging; +using static Android.Provider.Settings; +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +namespace Bit.TemplatePlayground.Client.Maui.Platforms.Android.Services; + +public partial class AndroidPushNotificationService : PushNotificationServiceBase +{ + public override async Task IsAvailable(CancellationToken cancellationToken) + { + return await MainThread.InvokeOnMainThreadAsync(async () => + { + return LocalNotificationCenter.Current.IsSupported + && await LocalNotificationCenter.Current.AreNotificationsEnabled(); + }); + } + + public override async Task RequestPermission(CancellationToken cancellationToken) + { + await MainThread.InvokeOnMainThreadAsync(async () => + { + if (LocalNotificationCenter.Current.IsSupported is false) + return; + + await LocalNotificationCenter.Current.RequestNotificationPermission(); + Configure(); + }); + } + + public string GetDeviceId() => Secure.GetString(Platform.AppContext.ContentResolver, Secure.AndroidId)!; + + public override async Task GetSubscription(CancellationToken cancellationToken) + { + try + { + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(15)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + + while (string.IsNullOrEmpty(Token)) + { + // After the NotificationsSupported Task completes with a result of true, + // we use FirebaseMessaging.Instance.GetToken. + // This method is asynchronous and we need to wait for it to complete. + await Task.Delay(TimeSpan.FromSeconds(1), linkedCts.Token); + } + } + catch (Exception exp) + { + Logger.LogError(exp, "Unable to resolve token for FCMv1."); + return null; + } + + var subscription = new PushNotificationSubscriptionDto + { + DeviceId = GetDeviceId(), + Platform = "fcmV1", + PushChannel = Token + }; + + return subscription; + } + + private static bool _isConfigured = false; + public static void Configure() + { + if (_isConfigured) + return; + _isConfigured = true; + FirebaseMessaging.Instance.GetToken().AddOnSuccessListener((MainActivity)Platform.CurrentActivity!); + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Services/PushNotificationFirebaseMessagingService.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Services/PushNotificationFirebaseMessagingService.cs new file mode 100644 index 00000000..a1e4c5b7 --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Android/Services/PushNotificationFirebaseMessagingService.cs @@ -0,0 +1,59 @@ +using Android.App; +using Android.Content; +using Firebase.Messaging; +using Plugin.LocalNotification; + +namespace Bit.TemplatePlayground.Client.Maui.Platforms.Android.Services; + +[Service(Exported = false)] +[IntentFilter(["com.google.firebase.MESSAGING_EVENT"])] +public partial class PushNotificationFirebaseMessagingService : FirebaseMessagingService +{ + private IPushNotificationService PushNotificationService => IPlatformApplication.Current!.Services.GetRequiredService(); + + public override async void OnNewToken(string token) + { + try + { + PushNotificationService.Token = token; + + await PushNotificationService.Subscribe(default); + } + catch (Exception exp) + { + IPlatformApplication.Current!.Services.GetRequiredService().Handle(exp); + } + } + + public override async void OnMessageReceived(RemoteMessage message) + { + try + { + base.OnMessageReceived(message); + + var services = IPlatformApplication.Current!.Services; + var localizer = services.GetRequiredService>(); + + // Use the following code to get the action value from the push notification. + // message.Data.TryGetValue("action", out var messageAction); + + var notification = message.GetNotification(); + var title = notification!.Title; + var body = notification.Body; + + if (string.IsNullOrEmpty(title) is false) + { + await LocalNotificationCenter.Current.Show(new() + { + Title = title!, + Description = body!, + ReturningData = JsonSerializer.Serialize(message.Data ?? new Dictionary { }) + }); + } + } + catch (Exception exp) + { + IPlatformApplication.Current!.Services.GetRequiredService().Handle(exp); + } + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/AppDelegate.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/AppDelegate.cs index ada20391..4f081f62 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/AppDelegate.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/AppDelegate.cs @@ -1,5 +1,7 @@ using UIKit; using Foundation; +using UserNotifications; +using Bit.TemplatePlayground.Client.Maui.Platforms.MacCatalyst.Services; namespace Bit.TemplatePlayground.Client.Maui.Platforms.MacCatalyst; @@ -8,10 +10,48 @@ public partial class AppDelegate : MauiUIApplicationDelegate { protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + private IPushNotificationService NotificationService => IPlatformApplication.Current!.Services.GetRequiredService(); + + [Export("application:didFinishLaunchingWithOptions:")] public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions) { + NotificationService.IsAvailable(default).ContinueWith(async task => + { + if (task.Result) + { + await MacCatalystPushNotificationService.Configure(); + } + }); + + // Use the following code the get the action value from the push notification when the app is launched by tapping on the push notification. + using var userInfo = launchOptions?.ObjectForKey(UIApplication.LaunchOptionsRemoteNotificationKey) as NSDictionary; + // var actionValue = userInfo?.ObjectForKey(new NSString("action")) as NSString; + var pageUrl = userInfo?.ObjectForKey(new NSString("pageUrl")) as NSString; + if (pageUrl != null) + { + _ = Core.Components.Routes.OpenUniversalLink(pageUrl); + } return base.FinishedLaunching(application, launchOptions!); } + [Export("application:didRegisterForRemoteNotificationsWithDeviceToken:")] + public async void RegisteredForRemoteNotifications(UIApplication application, NSData deviceToken) + { + try + { + NotificationService.Token = deviceToken.ToHexString()!; + await NotificationService.Subscribe(default); + } + catch (Exception exp) + { + IPlatformApplication.Current!.Services.GetRequiredService().Handle(exp); + } + } + + [Export("application:didFailToRegisterForRemoteNotificationsWithError:")] + public void FailedToRegisterForRemoteNotifications(UIApplication application, NSError error) + { + IPlatformApplication.Current!.Services.GetRequiredService().Handle(new InvalidOperationException(error.Description.ToString())); + } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Development.plist b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Development.plist index b90ca8fc..b72ce227 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Development.plist +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Development.plist @@ -6,5 +6,7 @@ com.apple.security.get-task-allow + aps-environment + development \ No newline at end of file diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Production.plist b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Production.plist index 7628096d..b3e85066 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Production.plist +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Entitlements.Production.plist @@ -10,5 +10,7 @@ com.apple.security.cs.allow-jit + aps-environment + production \ No newline at end of file diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Extensions/IMacServiceCollectionExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Extensions/IMacServiceCollectionExtensions.cs index 06fe653e..b133973e 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Extensions/IMacServiceCollectionExtensions.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Extensions/IMacServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Bit.TemplatePlayground.Client.Maui.Platforms.MacCatalyst.Services; namespace Microsoft.Extensions.DependencyInjection; @@ -7,6 +8,7 @@ public static IServiceCollection AddClientMauiProjectMacCatalystServices(this IS { // Services being registered here can get injected in Maui/macOS. + services.AddSingleton(); return services; } diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Extensions/NSDataExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Extensions/NSDataExtensions.cs new file mode 100644 index 00000000..f4de7fec --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Extensions/NSDataExtensions.cs @@ -0,0 +1,21 @@ +using System.Text; + +namespace Foundation; + +public static partial class NSDataExtensions +{ + public static string? ToHexString(this NSData data) + { + var bytes = data.ToArray(); + + if (bytes == null) + return null; + + StringBuilder sb = new StringBuilder(bytes.Length * 2); + + foreach (byte b in bytes) + sb.AppendFormat("{0:x2}", b); + + return sb.ToString().ToUpperInvariant(); + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Info.plist b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Info.plist index 57bad79b..d4727d73 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Info.plist +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Info.plist @@ -43,5 +43,10 @@ XSAppIconAssets Assets.xcassets/appicon.appiconset + UIBackgroundModes + + fetch + remote-notification + \ No newline at end of file diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Services/AppUNUserNotificationCenterDelegate.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Services/AppUNUserNotificationCenterDelegate.cs new file mode 100644 index 00000000..f95f76bd --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Services/AppUNUserNotificationCenterDelegate.cs @@ -0,0 +1,29 @@ +using Foundation; +using UserNotifications; + +namespace Bit.TemplatePlayground.Client.Maui.Platforms.MacCatalyst.Services; +public partial class AppUNUserNotificationCenterDelegate : UNUserNotificationCenterDelegate +{ + public override void DidReceiveNotificationResponse(UNUserNotificationCenter center, UNNotificationResponse response, Action completionHandler) + { + // Runs when user taps on push notification. + // Use the following code to get the action value from the tapped push notification. + // var actionValue = response.Notification.Request.Content.UserInfo.ObjectForKey(new NSString("action")) as NSString; + var pageUrl = response.Notification.Request.Content.UserInfo.ObjectForKey(new NSString("pageUrl")) as NSString; + if (pageUrl != null) + { + _ = Core.Components.Routes.OpenUniversalLink(pageUrl); + } + } + + public override void WillPresentNotification(UNUserNotificationCenter center, UNNotification notification, Action completionHandler) + { + // Displays the notification when the app is in the foreground. + completionHandler(UNNotificationPresentationOptions.Alert | + UNNotificationPresentationOptions.Badge | + UNNotificationPresentationOptions.Sound); + + // Use the following code to get the action value from the push notification. + // var actionValue = notification.Request.Content.UserInfo.ObjectForKey(new NSString("action")) as NSString; + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Services/MacCatalystPushNotificationService.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Services/MacCatalystPushNotificationService.cs new file mode 100644 index 00000000..7fcf405f --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/MacCatalyst/Services/MacCatalystPushNotificationService.cs @@ -0,0 +1,72 @@ +using UIKit; +using UserNotifications; +using Plugin.LocalNotification; +using Microsoft.Extensions.Logging; +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +namespace Bit.TemplatePlayground.Client.Maui.Platforms.MacCatalyst.Services; + +public partial class MacCatalystPushNotificationService : PushNotificationServiceBase +{ + public override async Task IsAvailable(CancellationToken cancellationToken) + { + return await MainThread.InvokeOnMainThreadAsync(async () => + { + return LocalNotificationCenter.Current.IsSupported + && await LocalNotificationCenter.Current.AreNotificationsEnabled(); + }); + } + + public override async Task RequestPermission(CancellationToken cancellationToken) + { + await MainThread.InvokeOnMainThreadAsync(async () => + { + if (LocalNotificationCenter.Current.IsSupported is false) + return; + + await LocalNotificationCenter.Current.RequestNotificationPermission(); + await Configure(); + }); + } + + public string GetDeviceId() => UIDevice.CurrentDevice.IdentifierForVendor!.ToString(); + + public override async Task GetSubscription(CancellationToken cancellationToken) + { + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(15)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + + try + { + while (string.IsNullOrEmpty(Token)) + { + // After the NotificationsSupported Task completes with a result of true, + // we use UNUserNotificationCenter.Current.Delegate. + // This method is asynchronous and we need to wait for it to complete. + await Task.Delay(TimeSpan.FromSeconds(1), linkedCts.Token); + } + } + catch (Exception exp) + { + Logger.LogError(exp, "Unable to resolve token for APNS."); + } + + var subscription = new PushNotificationSubscriptionDto + { + DeviceId = GetDeviceId(), + Platform = "apns", + PushChannel = Token + }; + + return subscription; + } + + public static async Task Configure() + { + await MainThread.InvokeOnMainThreadAsync(() => + { + UIApplication.SharedApplication.RegisterForRemoteNotifications(); + UNUserNotificationCenter.Current.Delegate = new AppUNUserNotificationCenterDelegate(); + }); + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Windows/Extensions/IWindowsServiceCollectionExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Windows/Extensions/IWindowsServiceCollectionExtensions.cs index 2cd7046d..74175a20 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Windows/Extensions/IWindowsServiceCollectionExtensions.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Windows/Extensions/IWindowsServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Bit.TemplatePlayground.Client.Maui.Platforms.Windows.Services; namespace Microsoft.Extensions.DependencyInjection; @@ -7,6 +8,7 @@ public static IServiceCollection AddClientMauiProjectWindowsServices(this IServi { // Services being registered here can get injected in Maui/windows. + services.AddSingleton(); return services; } diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Windows/Services/WindowsPushNotificationService.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Windows/Services/WindowsPushNotificationService.cs new file mode 100644 index 00000000..8641a70f --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/Windows/Services/WindowsPushNotificationService.cs @@ -0,0 +1,12 @@ +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +namespace Bit.TemplatePlayground.Client.Maui.Platforms.Windows.Services; + +public partial class WindowsPushNotificationService : PushNotificationServiceBase +{ + public override Task GetSubscription(CancellationToken cancellationToken) => + throw new NotImplementedException(); + + public override Task RequestPermission(CancellationToken cancellationToken) => + throw new NotImplementedException(); +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/AppDelegate.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/AppDelegate.cs index 1fa926f7..3b89b0d8 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/AppDelegate.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/AppDelegate.cs @@ -1,5 +1,6 @@ using UIKit; using Foundation; +using Bit.TemplatePlayground.Client.Maui.Platforms.iOS.Services; namespace Bit.TemplatePlayground.Client.Maui.Platforms.iOS; @@ -8,10 +9,48 @@ public partial class AppDelegate : MauiUIApplicationDelegate { protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + private IPushNotificationService NotificationService => IPlatformApplication.Current!.Services.GetRequiredService(); + + [Export("application:didFinishLaunchingWithOptions:")] public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions) { + NotificationService.IsAvailable(default).ContinueWith(async task => + { + if (task.Result) + { + await iOSPushNotificationService.Configure(); + } + }); + + // Use the following code the get the action value from the push notification when the app is launched by tapping on the push notification. + using var userInfo = launchOptions?.ObjectForKey(UIApplication.LaunchOptionsRemoteNotificationKey) as NSDictionary; + // var actionValue = userInfo?.ObjectForKey(new NSString("action")) as NSString; + var pageUrl = userInfo?.ObjectForKey(new NSString("pageUrl")) as NSString; + if (pageUrl != null) + { + _ = Core.Components.Routes.OpenUniversalLink(pageUrl); + } return base.FinishedLaunching(application, launchOptions!); } + [Export("application:didRegisterForRemoteNotificationsWithDeviceToken:")] + public async void RegisteredForRemoteNotifications(UIApplication application, NSData deviceToken) + { + try + { + NotificationService.Token = deviceToken.ToHexString()!; + await NotificationService.Subscribe(default); + } + catch (Exception exp) + { + IPlatformApplication.Current!.Services.GetRequiredService().Handle(exp); + } + } + + [Export("application:didFailToRegisterForRemoteNotificationsWithError:")] + public void FailedToRegisterForRemoteNotifications(UIApplication application, NSError error) + { + IPlatformApplication.Current!.Services.GetRequiredService().Handle(new InvalidOperationException(error.Description.ToString())); + } } diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Development.plist b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Development.plist index f66c60fa..14f24b0d 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Development.plist +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Development.plist @@ -6,5 +6,7 @@ applinks:use-your-web-app-url-here.com + aps-environment + development diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Production.plist b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Production.plist index f66c60fa..9cc9e1e5 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Production.plist +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Entitlements.Production.plist @@ -6,5 +6,7 @@ applinks:use-your-web-app-url-here.com + aps-environment + production diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Extensions/IIosServiceCollectionExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Extensions/IIosServiceCollectionExtensions.cs index a510c3d4..c127a154 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Extensions/IIosServiceCollectionExtensions.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Extensions/IIosServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Bit.TemplatePlayground.Client.Maui.Platforms.iOS.Services; namespace Microsoft.Extensions.DependencyInjection; @@ -7,6 +8,7 @@ public static IServiceCollection AddClientMauiProjectIosServices(this IServiceCo { // Services registered in this class can be injected in iOS. + services.AddSingleton(); return services; } diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Extensions/NSDataExtensions.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Extensions/NSDataExtensions.cs new file mode 100644 index 00000000..f4de7fec --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Extensions/NSDataExtensions.cs @@ -0,0 +1,21 @@ +using System.Text; + +namespace Foundation; + +public static partial class NSDataExtensions +{ + public static string? ToHexString(this NSData data) + { + var bytes = data.ToArray(); + + if (bytes == null) + return null; + + StringBuilder sb = new StringBuilder(bytes.Length * 2); + + foreach (byte b in bytes) + sb.AppendFormat("{0:x2}", b); + + return sb.ToString().ToUpperInvariant(); + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Info.plist b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Info.plist index 4b84a0f5..5b05d897 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Info.plist +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Info.plist @@ -32,5 +32,10 @@ XSAppIconAssets Assets.xcassets/appicon.appiconset + UIBackgroundModes + + fetch + remote-notification + diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Services/AppUNUserNotificationCenterDelegate.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Services/AppUNUserNotificationCenterDelegate.cs new file mode 100644 index 00000000..e6bb5c75 --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Services/AppUNUserNotificationCenterDelegate.cs @@ -0,0 +1,29 @@ +using Foundation; +using UserNotifications; + +namespace Bit.TemplatePlayground.Client.Maui.Platforms.iOS.Services; +public partial class AppUNUserNotificationCenterDelegate : UNUserNotificationCenterDelegate +{ + public override void DidReceiveNotificationResponse(UNUserNotificationCenter center, UNNotificationResponse response, Action completionHandler) + { + // Runs when user taps on push notification. + // Use the following code to get the action value from the tapped push notification. + // var actionValue = response.Notification.Request.Content.UserInfo.ObjectForKey(new NSString("action")) as NSString; + var pageUrl = response.Notification.Request.Content.UserInfo.ObjectForKey(new NSString("pageUrl")) as NSString; + if (pageUrl != null) + { + _ = Core.Components.Routes.OpenUniversalLink(pageUrl); + } + } + + public override void WillPresentNotification(UNUserNotificationCenter center, UNNotification notification, Action completionHandler) + { + // Displays the notification when the app is in the foreground. + completionHandler(UNNotificationPresentationOptions.Alert | + UNNotificationPresentationOptions.Badge | + UNNotificationPresentationOptions.Sound); + + // Use the following code to get the action value from the push notification. + // var actionValue = notification.Request.Content.UserInfo.ObjectForKey(new NSString("action")) as NSString; + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Services/iOSPushNotificationService.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Services/iOSPushNotificationService.cs new file mode 100644 index 00000000..fdbb6b58 --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Platforms/iOS/Services/iOSPushNotificationService.cs @@ -0,0 +1,72 @@ +using UIKit; +using UserNotifications; +using Plugin.LocalNotification; +using Microsoft.Extensions.Logging; +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +namespace Bit.TemplatePlayground.Client.Maui.Platforms.iOS.Services; + +public partial class iOSPushNotificationService : PushNotificationServiceBase +{ + public override async Task IsAvailable(CancellationToken cancellationToken) + { + return await MainThread.InvokeOnMainThreadAsync(async () => + { + return LocalNotificationCenter.Current.IsSupported + && await LocalNotificationCenter.Current.AreNotificationsEnabled(); + }); + } + + public override async Task RequestPermission(CancellationToken cancellationToken) + { + await MainThread.InvokeOnMainThreadAsync(async () => + { + if (LocalNotificationCenter.Current.IsSupported is false) + return; + + await LocalNotificationCenter.Current.RequestNotificationPermission(); + await Configure(); + }); + } + + public string GetDeviceId() => UIDevice.CurrentDevice.IdentifierForVendor!.ToString(); + + public override async Task GetSubscription(CancellationToken cancellationToken) + { + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(15)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + + try + { + while (string.IsNullOrEmpty(Token)) + { + // After the NotificationsSupported Task completes with a result of true, + // we use UNUserNotificationCenter.Current.Delegate. + // This method is asynchronous and we need to wait for it to complete. + await Task.Delay(TimeSpan.FromSeconds(1), linkedCts.Token); + } + } + catch (Exception exp) + { + Logger.LogError(exp, "Unable to resolve token for APNS."); + } + + var subscription = new PushNotificationSubscriptionDto + { + DeviceId = GetDeviceId(), + Platform = "apns", + PushChannel = Token + }; + + return subscription; + } + + public static async Task Configure() + { + await MainThread.InvokeOnMainThreadAsync(() => + { + UIApplication.SharedApplication.RegisterForRemoteNotifications(); + UNUserNotificationCenter.Current.Delegate = new AppUNUserNotificationCenterDelegate(); + }); + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/Services/MauiLocalHttpServer.cs b/src/Client/Bit.TemplatePlayground.Client.Maui/Services/MauiLocalHttpServer.cs index 55206e8e..7537e52c 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/Services/MauiLocalHttpServer.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/Services/MauiLocalHttpServer.cs @@ -152,8 +152,8 @@ await MainThread.InvokeOnMainThreadAsync(() => })) .OnAny(async ctx => { - var ctxImpl = (IHttpContextImpl)ctx; - var requestFilePath = ctxImpl.Request.Url.LocalPath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar); + var ctxImplementation = (IHttpContextImpl)ctx; + var requestFilePath = ctxImplementation.Request.Url.LocalPath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar); Stream? staticFileStream = null; if (staticFiles.FirstOrDefault(f => f.EndsWith(requestFilePath, StringComparison.OrdinalIgnoreCase)) is string staticFilePath) { diff --git a/src/Client/Bit.TemplatePlayground.Client.Maui/wwwroot/index.html b/src/Client/Bit.TemplatePlayground.Client.Maui/wwwroot/index.html index d1b3803d..e11f7ba5 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Maui/wwwroot/index.html +++ b/src/Client/Bit.TemplatePlayground.Client.Maui/wwwroot/index.html @@ -131,7 +131,7 @@
- + diff --git a/src/Client/Bit.TemplatePlayground.Client.Web/ClientWebSettings.cs b/src/Client/Bit.TemplatePlayground.Client.Web/ClientWebSettings.cs index 8529af07..80eed7b2 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Web/ClientWebSettings.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Web/ClientWebSettings.cs @@ -4,13 +4,34 @@ namespace Bit.TemplatePlayground.Client.Web; public class ClientWebSettings : ClientCoreSettings { + public AdsPushVapidOptions? AdsPushVapid { get; set; } public override IEnumerable Validate(ValidationContext validationContext) { var validationResults = base.Validate(validationContext).ToList(); + if (AdsPushVapid is not null) + { + Validator.TryValidateObject(AdsPushVapid, new ValidationContext(AdsPushVapid), validationResults, true); + + if (AppEnvironment.IsDevelopment() is false && AdsPushVapid.PublicKey is "BDSNUvuIISD8NQVByQANEtZ2foKaENIcIGUxsiQs9kDz11fQik8c9WeiMwUHs3iTgNNH4nvXioNQIEsn4OAjTKc") + { + validationResults.Add(new ValidationResult("Please set your own AdsPushVapid.PublicKey in Client.Core's appsettings.json")); + } + } return validationResults; } } +/// +/// https://github.com/adessoTurkey-dotNET/AdsPush +/// +public class AdsPushVapidOptions +{ + /// + /// Web push's vapid. More info at https://tools.reactpwa.com/vapid + /// + [Required] + public string PublicKey { get; set; } = default!; +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Web/Program.Services.cs b/src/Client/Bit.TemplatePlayground.Client.Web/Program.Services.cs index fd364f02..7d540b84 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Web/Program.Services.cs +++ b/src/Client/Bit.TemplatePlayground.Client.Web/Program.Services.cs @@ -47,6 +47,7 @@ public static void AddClientWebProjectServices(this IServiceCollection services, services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Client/Bit.TemplatePlayground.Client.Web/Properties/launchSettings.json b/src/Client/Bit.TemplatePlayground.Client.Web/Properties/launchSettings.json index d9306282..0475cfc9 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Web/Properties/launchSettings.json +++ b/src/Client/Bit.TemplatePlayground.Client.Web/Properties/launchSettings.json @@ -5,7 +5,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:4298", + "applicationUrl": "http://localhost:4097", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Client/Bit.TemplatePlayground.Client.Web/Services/WebPushNotificationService.cs b/src/Client/Bit.TemplatePlayground.Client.Web/Services/WebPushNotificationService.cs new file mode 100644 index 00000000..83f84bb5 --- /dev/null +++ b/src/Client/Bit.TemplatePlayground.Client.Web/Services/WebPushNotificationService.cs @@ -0,0 +1,36 @@ +using Bit.Butil; +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +namespace Bit.TemplatePlayground.Client.Web.Services; + +public partial class WebPushNotificationService : PushNotificationServiceBase +{ + [AutoInject] private Notification notification = default!; + [AutoInject] private readonly IJSRuntime jSRuntime = default!; + [AutoInject] private readonly ClientWebSettings clientWebSettings = default!; + + public override async Task GetSubscription(CancellationToken cancellationToken) + { + var subscription = await jSRuntime.GetPushNotificationSubscription(clientWebSettings.AdsPushVapid!.PublicKey); + + if (subscription is null) + { + Logger.LogError("Could not retrieve push notification subscription"); // Browser's incognito mode etc. + } + + return subscription; + } + + public override async Task IsAvailable(CancellationToken cancellationToken) => string.IsNullOrEmpty(clientWebSettings.AdsPushVapid?.PublicKey) is false && await notification.IsNotificationAvailable(); + + public override async Task RequestPermission(CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(clientWebSettings.AdsPushVapid?.PublicKey)) + return; + + if (await notification.IsSupported() is false) + return; + + await notification.RequestPermission(); + } +} diff --git a/src/Client/Bit.TemplatePlayground.Client.Web/appsettings.json b/src/Client/Bit.TemplatePlayground.Client.Web/appsettings.json index d81b30bf..c0f6401d 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Web/appsettings.json +++ b/src/Client/Bit.TemplatePlayground.Client.Web/appsettings.json @@ -1,3 +1,8 @@ { + "AdsPushVapid_Comment": "https://github.com/adessoTurkey-dotNET/AdsPush", + "AdsPushVapid": { + "AdsPushVapid_Comment": "Web push's vapid. More info at https://tools.reactpwa.com/vapid", + "PublicKey": "BDSNUvuIISD8NQVByQANEtZ2foKaENIcIGUxsiQs9kDz11fQik8c9WeiMwUHs3iTgNNH4nvXioNQIEsn4OAjTKc" + }, "$schema": "https://json.schemastore.org/appsettings.json" } diff --git a/src/Client/Bit.TemplatePlayground.Client.Web/wwwroot/index.html b/src/Client/Bit.TemplatePlayground.Client.Web/wwwroot/index.html index 0dcdd5e9..1ce877c8 100644 --- a/src/Client/Bit.TemplatePlayground.Client.Web/wwwroot/index.html +++ b/src/Client/Bit.TemplatePlayground.Client.Web/wwwroot/index.html @@ -185,8 +185,8 @@ - - + + - - + + - @* for PWA *@ + @* for PWA *@ - + - - + + + + + - - - - - - + + + + + + @if (renderMode != null && (serverWebSettings.WebAppRender.PrerenderEnabled is false || noPrerender)) { @@ -72,9 +75,9 @@ @if (HttpContext.Request.IsLightHouseRequest() is false) { - - - + + + diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Link.razor b/src/Server/Bit.TemplatePlayground.Server.Web/Components/Link.razor deleted file mode 100644 index 29199eb2..00000000 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Link.razor +++ /dev/null @@ -1,3 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.ViewFeatures - - diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Link.razor.cs b/src/Server/Bit.TemplatePlayground.Server.Web/Components/Link.razor.cs deleted file mode 100644 index 26fb34ed..00000000 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Link.razor.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Mvc.ViewFeatures; - -namespace Bit.TemplatePlayground.Server.Web.Components; - -public partial class Link -{ - [AutoInject] private IFileVersionProvider fileVersionProvider = default!; - [AutoInject] private IHttpContextAccessor httpContextAccessor = default!; - - [Parameter] public bool AppendVersion { get; set; } = true; - [Parameter] public required string Href { get; set; } = ""; - [Parameter(CaptureUnmatchedValues = true)] public Dictionary AdditionalAttributes { get; set; } = default!; - - private string href = ""; - - protected override void OnInitialized() - { - base.OnInitialized(); - href = AppendVersion ? fileVersionProvider.AddFileVersionToPath(httpContextAccessor.HttpContext!.Request.PathBase, Href) : Href; - } -} diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Script.razor b/src/Server/Bit.TemplatePlayground.Server.Web/Components/Script.razor deleted file mode 100644 index 1d53231f..00000000 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Script.razor +++ /dev/null @@ -1,12 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.ViewFeatures - -@if (src is not null) -{ - -} -else -{ - -} \ No newline at end of file diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Script.razor.cs b/src/Server/Bit.TemplatePlayground.Server.Web/Components/Script.razor.cs deleted file mode 100644 index b7dcd5e8..00000000 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Components/Script.razor.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Mvc.ViewFeatures; - -namespace Bit.TemplatePlayground.Server.Web.Components; - -public partial class Script -{ - [AutoInject] private IFileVersionProvider fileVersionProvider = default!; - [AutoInject] private IHttpContextAccessor httpContextAccessor = default!; - - [Parameter] public string? Src { get; set; } - [Parameter] public bool AppendVersion { get; set; } = true; - [Parameter] public RenderFragment? ChildContent { get; set; } - - [Parameter(CaptureUnmatchedValues = true)] - public Dictionary AdditionalAttributes { get; set; } = default!; - - - private string? src; - - protected override void OnInitialized() - { - base.OnInitialized(); - src = (Src is not null && AppendVersion) ? fileVersionProvider.AddFileVersionToPath(httpContextAccessor.HttpContext!.Request.PathBase, Src) : Src; - } -} diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Program.Middlewares.cs b/src/Server/Bit.TemplatePlayground.Server.Web/Program.Middlewares.cs index ecfab9ed..6a5a4d69 100644 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Program.Middlewares.cs +++ b/src/Server/Bit.TemplatePlayground.Server.Web/Program.Middlewares.cs @@ -102,7 +102,7 @@ public static void ConfigureMiddlewares(this WebApplication app) app.UseAntiforgery(); - app.MappAppHealthChecks(); + app.MapAppHealthChecks(); app.UseSwagger(); diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Program.Services.cs b/src/Server/Bit.TemplatePlayground.Server.Web/Program.Services.cs index 49c9f67d..3c4bbb4f 100644 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Program.Services.cs +++ b/src/Server/Bit.TemplatePlayground.Server.Web/Program.Services.cs @@ -108,7 +108,5 @@ private static void AddBlazor(WebApplicationBuilder builder) services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); - - services.AddMvc(); } } diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Program.cs b/src/Server/Bit.TemplatePlayground.Server.Web/Program.cs index dc58c5d4..8d26ae96 100644 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Program.cs +++ b/src/Server/Bit.TemplatePlayground.Server.Web/Program.cs @@ -1,7 +1,6 @@ -using Bit.TemplatePlayground.Client.Core.Services.Contracts; -using Bit.TemplatePlayground.Server.Api.Data; +using Bit.TemplatePlayground.Server.Api.Data; using Bit.TemplatePlayground.Server.Web.Services; -using Microsoft.EntityFrameworkCore; +using Bit.TemplatePlayground.Client.Core.Services.Contracts; namespace Bit.TemplatePlayground.Server.Web; @@ -9,6 +8,8 @@ public static partial class Program { public static async Task Main(string[] args) { + ConfigureGlobalization(); + var builder = WebApplication.CreateBuilder(options: new() { Args = args, @@ -31,7 +32,7 @@ public static async Task Main(string[] args) { await using var scope = app.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.MigrateAsync(); // It's recommended to start using ef-core migrations. + await dbContext.Database.EnsureCreatedAsync(); // It's recommended to start using ef-core migrations. } app.ConfigureMiddlewares(); @@ -58,4 +59,16 @@ private static void LogException(object? error, string reportedBy, WebApplicatio _ = Console.Error.WriteLineAsync(error?.ToString() ?? "Unknown error"); } } + + /// + /// You might consider setting `InvariantGlobalization` to `true` when publishing Server.Web and Blazor WebAssembly simultaneously, + /// as this can reduce the website's size. However, doing so would also make the server project culture-invariant, which offers minimal benefit + /// and could potentially cause issues.The following environment variable allows you to maintain server culture support + /// while reducing the client's size through invariant culture. + /// https://learn.microsoft.com/en-us/dotnet/core/runtime-config/globalization#invariant-mode + /// + private static void ConfigureGlobalization() + { + Environment.SetEnvironmentVariable("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "false"); + } } diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/Properties/launchSettings.json b/src/Server/Bit.TemplatePlayground.Server.Web/Properties/launchSettings.json index 9194fab7..75b2b117 100644 --- a/src/Server/Bit.TemplatePlayground.Server.Web/Properties/launchSettings.json +++ b/src/Server/Bit.TemplatePlayground.Server.Web/Properties/launchSettings.json @@ -50,7 +50,7 @@ "ASPNETCORE_ENVIRONMENT": "Development", "ConnectionStrings__SqliteConnectionString": "Data Source=/container_volume/App_Data/Bit.TemplatePlaygroundDb.db;" }, - "DockerfileRunArguments": "-v C:\\DockerVolumes\\EA9A5D98-4682-45A6-9AA0-E10F1B0AFD90:/container_volume", + "DockerfileRunArguments": "-v C:\\DockerVolumes\\7174F8A5-A1EE-4D5D-A477-DA5ED1FC1C36:/container_volume", "publishAllPorts": true, "useSSL": false, "httpPort": 5000 diff --git a/src/Server/Bit.TemplatePlayground.Server.Web/wwwroot/.gitkeep b/src/Server/Bit.TemplatePlayground.Server.Web/wwwroot/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/Shared/Controllers/Chatbot/IChatbotController.cs b/src/Shared/Controllers/Chatbot/IChatbotController.cs index 12672d8e..7a5f1448 100644 --- a/src/Shared/Controllers/Chatbot/IChatbotController.cs +++ b/src/Shared/Controllers/Chatbot/IChatbotController.cs @@ -6,8 +6,8 @@ namespace Bit.TemplatePlayground.Shared.Controllers.Chatbot; [Route("api/[controller]/[action]/")] public interface IChatbotController : IAppController { - [HttpGet("{kind}")] - Task GetSystemPrompt(PromptKind kind, CancellationToken cancellationToken); + [HttpGet] + Task> GetSystemPrompts(CancellationToken cancellationToken) => default!; [HttpPost] Task UpdateSystemPrompt(SystemPromptDto dto, CancellationToken cancellationToken); diff --git a/src/Shared/Controllers/PushNotification/IPushNotificationController.cs b/src/Shared/Controllers/PushNotification/IPushNotificationController.cs new file mode 100644 index 00000000..a214b753 --- /dev/null +++ b/src/Shared/Controllers/PushNotification/IPushNotificationController.cs @@ -0,0 +1,10 @@ +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +namespace Bit.TemplatePlayground.Shared.Controllers.PushNotification; + +[Route("api/[controller]/[action]/")] +public interface IPushNotificationController : IAppController +{ + [HttpPost] + Task Subscribe([Required] PushNotificationSubscriptionDto subscription, CancellationToken cancellationToken); +} diff --git a/src/Shared/Dtos/AppJsonContext.cs b/src/Shared/Dtos/AppJsonContext.cs index 708f3735..3f85befa 100644 --- a/src/Shared/Dtos/AppJsonContext.cs +++ b/src/Shared/Dtos/AppJsonContext.cs @@ -1,6 +1,7 @@ using Bit.TemplatePlayground.Shared.Dtos.Dashboard; using Bit.TemplatePlayground.Shared.Dtos.Products; using Bit.TemplatePlayground.Shared.Dtos.Categories; +using Bit.TemplatePlayground.Shared.Dtos.PushNotification; using Bit.TemplatePlayground.Shared.Dtos.Chatbot; using Bit.TemplatePlayground.Shared.Dtos.Identity; using Bit.TemplatePlayground.Shared.Dtos.Statistics; @@ -21,6 +22,7 @@ namespace Bit.TemplatePlayground.Shared.Dtos; [JsonSerializable(typeof(NugetStatsDto))] [JsonSerializable(typeof(AppProblemDetails))] [JsonSerializable(typeof(SendNotificationToRoleDto))] +[JsonSerializable(typeof(PushNotificationSubscriptionDto))] [JsonSerializable(typeof(CategoryDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(PagedResult))] @@ -35,7 +37,7 @@ namespace Bit.TemplatePlayground.Shared.Dtos; [JsonSerializable(typeof(DiagnosticLogDto[]))] [JsonSerializable(typeof(StartChatbotRequest))] -[JsonSerializable(typeof(SystemPromptDto))] +[JsonSerializable(typeof(List))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/Shared/Dtos/Chatbot/StartChatbotRequest.cs b/src/Shared/Dtos/Chatbot/StartChatbotRequest.cs index 0e42e31b..a9ed4b00 100644 --- a/src/Shared/Dtos/Chatbot/StartChatbotRequest.cs +++ b/src/Shared/Dtos/Chatbot/StartChatbotRequest.cs @@ -30,3 +30,8 @@ public class AiChatMessage [JsonIgnore] public bool Successful { get; set; } = true; } + +public class AiChatFollowUpList +{ + public List FollowUpSuggestions { get; set; } = []; +} diff --git a/src/Shared/Dtos/PushNotification/PushNotificationSubscriptionDto.cs b/src/Shared/Dtos/PushNotification/PushNotificationSubscriptionDto.cs new file mode 100644 index 00000000..4a878e3f --- /dev/null +++ b/src/Shared/Dtos/PushNotification/PushNotificationSubscriptionDto.cs @@ -0,0 +1,18 @@ +namespace Bit.TemplatePlayground.Shared.Dtos.PushNotification; + +[DtoResourceType(typeof(AppStrings))] +public partial class PushNotificationSubscriptionDto +{ + [Required(ErrorMessage = nameof(AppStrings.RequiredAttribute_ValidationError))] + public string? DeviceId { get; set; } + + [Required(ErrorMessage = nameof(AppStrings.RequiredAttribute_ValidationError))] + [AllowedValues("apns", "fcmV1", "browser")] + /// fcmV1 + public string? Platform { get; set; } + + public string? PushChannel { get; set; } + public string? P256dh { get; set; } + public string? Auth { get; set; } + public string? Endpoint { get; set; } +} diff --git a/src/Shared/Exceptions/TooManyRequestsException.cs b/src/Shared/Exceptions/TooManyRequestsException.cs new file mode 100644 index 00000000..bf5e44a8 --- /dev/null +++ b/src/Shared/Exceptions/TooManyRequestsException.cs @@ -0,0 +1,33 @@ +using System.Net; + +namespace Bit.TemplatePlayground.Shared.Exceptions; + +public partial class TooManyRequestsException : RestException +{ + public TooManyRequestsException() + : base(nameof(AppStrings.TooManyRequestExceptions)) + { + } + + public TooManyRequestsException(string message) + : base(message) + { + } + + public TooManyRequestsException(string message, Exception? innerException) + : base(message, innerException) + { + } + + public TooManyRequestsException(LocalizedString message) + : base(message) + { + } + + public TooManyRequestsException(LocalizedString message, Exception? innerException) + : base(message, innerException) + { + } + + public override HttpStatusCode StatusCode => HttpStatusCode.TooManyRequests; +} diff --git a/src/Shared/Exceptions/TooManyRequestsExceptions.cs b/src/Shared/Exceptions/TooManyRequestsExceptions.cs deleted file mode 100644 index 49618347..00000000 --- a/src/Shared/Exceptions/TooManyRequestsExceptions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Net; - -namespace Bit.TemplatePlayground.Shared.Exceptions; - -public partial class TooManyRequestsExceptions : RestException -{ - public TooManyRequestsExceptions() - : base(nameof(AppStrings.TooManyRequestsExceptions)) - { - } - - public TooManyRequestsExceptions(string message) - : base(message) - { - } - - public TooManyRequestsExceptions(string message, Exception? innerException) - : base(message, innerException) - { - } - - public TooManyRequestsExceptions(LocalizedString message) - : base(message) - { - } - - public TooManyRequestsExceptions(LocalizedString message, Exception? innerException) - : base(message, innerException) - { - } - - public override HttpStatusCode StatusCode => HttpStatusCode.TooManyRequests; -} diff --git a/src/Shared/Resources/AppStrings.fa.resx b/src/Shared/Resources/AppStrings.fa.resx index 25f7b0cf..af9d615c 100644 --- a/src/Shared/Resources/AppStrings.fa.resx +++ b/src/Shared/Resources/AppStrings.fa.resx @@ -171,7 +171,7 @@ منبع یافت نشد - + درخواست های بیش از حد @@ -1090,9 +1090,6 @@ پیامی را وارد کنید... - - - درحال فکر کردن... لطفا صبر کنید diff --git a/src/Shared/Resources/AppStrings.resx b/src/Shared/Resources/AppStrings.resx index 9836fb82..cca349b6 100644 --- a/src/Shared/Resources/AppStrings.resx +++ b/src/Shared/Resources/AppStrings.resx @@ -171,7 +171,7 @@ Resource not found - + Too many requests @@ -1090,9 +1090,6 @@ After reaching {0}, extra sign-ins will have reduced functions. Write a message... - - - Thinking... Please wait diff --git a/src/Shared/Services/AppActivitySource.cs b/src/Shared/Services/AppActivitySource.cs new file mode 100644 index 00000000..d6f7c4ac --- /dev/null +++ b/src/Shared/Services/AppActivitySource.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.Metrics; + +namespace Bit.TemplatePlayground.Shared.Services; + +/// +/// Open telemetry activity source for the application. +/// +public class AppActivitySource +{ + public static readonly ActivitySource CurrentActivity = new("Bit.TemplatePlayground", typeof(AppActivitySource).Assembly.GetName().Version!.ToString()); + + public static readonly Meter CurrentMeter = new("Bit.TemplatePlayground", typeof(AppActivitySource).Assembly.GetName().Version!.ToString()); +} diff --git a/src/Shared/appsettings.Development.json b/src/Shared/appsettings.Development.json index c315469c..bb52b177 100644 --- a/src/Shared/appsettings.Development.json +++ b/src/Shared/appsettings.Development.json @@ -7,6 +7,14 @@ "System.Net.Http.HttpClient.*.LogicalHandler": "Warning", "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware": "None" }, + "OpenTelemetry": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore*": "Warning", + "System.Net.Http.HttpClient.*.LogicalHandler": "Warning", + "Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware": "None" + } + }, "Console": { "LogLevel": { "Default": "Information", diff --git a/src/Shared/appsettings.json b/src/Shared/appsettings.json index 67873753..425f05e9 100644 --- a/src/Shared/appsettings.json +++ b/src/Shared/appsettings.json @@ -6,6 +6,15 @@ "Microsoft.EntityFrameworkCore.Database.Command": "Information", "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None" }, + "OpenTelemetry": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Bit.TemplatePlayground.Client.Core.Services.AuthManager": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None" + } + }, "Console": { "LogLevel": { "Default": "Warning", diff --git a/src/Tests/.runsettings b/src/Tests/.runsettings index 09482478..fe379a03 100644 --- a/src/Tests/.runsettings +++ b/src/Tests/.runsettings @@ -22,6 +22,9 @@ + + + Data Source=Bit.TemplatePlaygroundDb.db;Mode=Memory;Cache=Shared; diff --git a/src/Tests/AppTestServer.cs b/src/Tests/AppTestServer.cs index 322fa3a9..1d57a51a 100644 --- a/src/Tests/AppTestServer.cs +++ b/src/Tests/AppTestServer.cs @@ -34,9 +34,6 @@ public AppTestServer Build(Action? configureTestServices = n builder.Configuration.AddClientConfigurations(clientEntryAssemblyName: "Bit.TemplatePlayground.Client.Web"); - //Use in-memory Sqlite database for faster and more reliable testing - builder.Configuration["ConnectionStrings:SqliteConnectionString"] = "Data Source=Bit.TemplatePlaygroundDb.db;Mode=Memory;Cache=Shared;"; - configureTestConfigurations?.Invoke(builder.Configuration); builder.AddTestProjectServices(); diff --git a/src/Tests/Bit.TemplatePlayground.Tests.csproj b/src/Tests/Bit.TemplatePlayground.Tests.csproj index e3d02ae2..ae2ff28b 100644 --- a/src/Tests/Bit.TemplatePlayground.Tests.csproj +++ b/src/Tests/Bit.TemplatePlayground.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Tests/Extensions/WebApplicationBuilderExtensions.cs b/src/Tests/Extensions/WebApplicationBuilderExtensions.cs index 798e540d..8d3dbb89 100644 --- a/src/Tests/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Tests/Extensions/WebApplicationBuilderExtensions.cs @@ -1,4 +1,5 @@ -using Bit.TemplatePlayground.Server.Web; +using Hangfire; +using Bit.TemplatePlayground.Server.Web; using Bit.TemplatePlayground.Tests.Services; using Bit.TemplatePlayground.Server.Api.Services; using Bit.TemplatePlayground.Client.Core.Services.HttpMessageHandlers; @@ -11,6 +12,8 @@ public static void AddTestProjectServices(this WebApplicationBuilder builder) { var services = builder.Services; + services.AddHangfire(configuration => configuration.UseColouredConsoleLogProvider()); + builder.AddServerWebProjectServices(); // Register test-specific services for all tests here diff --git a/src/Tests/IdentityApiTests.cs b/src/Tests/IdentityApiTests.cs index 07a33350..c0d55ddb 100644 --- a/src/Tests/IdentityApiTests.cs +++ b/src/Tests/IdentityApiTests.cs @@ -1,4 +1,4 @@ -using Bit.TemplatePlayground.Client.Core.Services; +using Bit.TemplatePlayground.Client.Core.Services; using Bit.TemplatePlayground.Client.Core.Services.Contracts; using Bit.TemplatePlayground.Shared.Controllers.Identity; using Bit.TemplatePlayground.Tests.Services; @@ -28,11 +28,11 @@ await authenticationManager.SignIn(new() { Email = TestData.DefaultTestEmail, Password = TestData.DefaultTestPassword - }, default); + }, TestContext.CancellationTokenSource.Token); var userController = scope.ServiceProvider.GetRequiredService(); - var user = await userController.GetCurrentUser(default); + var user = await userController.GetCurrentUser(TestContext.CancellationTokenSource.Token); Assert.AreEqual(Guid.Parse("8ff71671-a1d6-4f97-abb9-d87d7b47d6e7"), user.Id); } @@ -52,6 +52,8 @@ await server.Build(services => var userController = scope.ServiceProvider.GetRequiredService(); - await Assert.ThrowsExceptionAsync(() => userController.GetCurrentUser(default)); + await Assert.ThrowsExactlyAsync(() => userController.GetCurrentUser(TestContext.CancellationTokenSource.Token)); } + + public TestContext TestContext { get; set; } = default!; } diff --git a/vs-spell.dic b/vs-spell.dic new file mode 100644 index 00000000..b90b63d0 --- /dev/null +++ b/vs-spell.dic @@ -0,0 +1,105 @@ +webp +yyyy +dd +mm +resx +nameof +Mapperly +Blazor +Json +Urls +urlset +Postgre +enums +queryable +editorconfig +Hangfire +rendermode +Diag +hrefs +unsubscribers +tolower +Newtonsoft +nonfile +nuget +evenodd +gmail +Authn +Passwordless +linux +dtos +Recaptcha +grecaptcha +itunes +azurewebsites +validatable +preconnect +sqlite +Besql +Sessioned +appsettings +Butil +cloudflare +webauthn +scss +apns +bswup +webassembly +odata +ipcountry +ipcity +otpauth +totp +upgradeaccount +benz +nissan +Cybertruck +postgres +pgdata +pgvector +postgresql +orderby +aspnetcore +siteverify +packageid +apis +Middlewares +minioadmin +minio +openid +Signing +sqlserver +postgresdb +mysqlserver +mysqldb +serverweb +serverapi +clientwebwasm +clientwindows +mssql +sqldb +postgresserver +pgadmin +Otlp +devtunnels +github +cacheability +codespaces +webview +blazorui +yandex +bing +duckduck +sitemapindex +Antiforgery +Scss +slnx +slnf +nameid +Nederlands +svenska +español +français +Sqlite +healthchecks +healthz \ No newline at end of file