Click here to Skip to main content
15,919,931 members
Articles / MAUI

.NET MAUI Standard Error Popup Pattern

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
21 May 2024CPOL5 min read 2K   2   2
This article offers a comprehensive guide for developers to enhance the user experience of their applications through informative error handling and user empowerment features.

Screenshot_1714413514

Creating a User-Friendly Application: Inform, Engage, and Empower

In the realm of software development, crafting an application that not only meets functional requirements but also delivers a seamless and intuitive user experience is paramount. The adage “what, when, where” encapsulates the essence of user-friendly design, emphasizing the need to inform users about what happened, what it means, and what they can do about it. Additionally, guiding users on where to get more information and how to get help is crucial for fostering a sense of engagement and empowerment.

What Happened & What It Means

When an exception occurs, providing users with clear and concise information about the issue is essential. This involves explaining the nature of the problem and its implications in a language that is accessible to non-technical users. By demystifying errors, users are less likely to feel frustrated and more inclined to understand the situation.

What Users Can Do About It

Equipping users with actionable steps or solutions in response to an issue empowers them to resolve problems independently or navigate them with confidence. Whether it’s a simple fix, a workaround, or a directive to seek further assistance, the goal is to make users feel in control of the situation.  

Where to Get More Information

Offering resources for users to delve deeper into the issue at hand is another facet of a user-friendly application. This could be in the form of FAQs, help articles, or forums where users can seek advice from the community or support teams.  

How to Get Help

Ensuring that users have easy access to support channels is vital. This may include contact information for customer service, links to support tickets, or live chat options. The key is to make the process of seeking help as straightforward as possible.

Moving to Production: User Approval for Data Collection

As applications transition closer to production, the collection of user data for diagnostic purposes becomes a sensitive matter. It is imperative to seek user consent before sending any information to the developers. This not only respects user privacy but also builds trust.

The Code

Overview

Implementing these features can be efficiently managed using .NET Maui control templates. This approach allows for a modular and scalable design, where different aspects of user interaction can be encapsulated and managed independently. I’ll go over the code in the following sections.

.NET Maui documentation for content templates can be fond here.

In my solution I created a folder called CustomerControls and add a class file for each customer control I am going to use in my project. In the styles folder under Resources I added a file called ControlTemplates.xaml. In this file I put all my ContentTemplates for my custom controls.

I have also added a class file under Data called ErrorDictionary.

The CustomControls file ErrorPopUpView.cs contains the binding information for your control and is the base class for your custom control. While the ContraolTemplates.xaml is where you define the visualization of your control in XAML. The ErrorPopUpView.cs basically provides the data parameters that you pass into your control. IE your data bindings used by your control.

Image 2

Error Dictionary

Let’s take a look at ErrorDictionary.cs first. The code is pretty simple at this point. It simply loads a list of error information from an embedded file in the project. At a future date I’ll update the code so that the information is pulled down from the cloud and cached in a local file. That way enhancements and updates to the error information can be made without the need to redeploy the app. The class ErrorDetails row 16 below is the data that is defined for each error code, here is where you would extend this class to provide any additional information for your standard error popup. Line 48 is where you get the details for an error code and it provides for a default set of information if the error code is not found. Here you could log or send information back to the developer for unknown error codes to handle proactively.

C#
using CommunityToolkit.Mvvm.ComponentModel;

using Microsoft.Maui.Storage;

using Newtonsoft.Json;

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MyNextBook.Models
{
    public class ErrorDetails
    {
        public string ErrorCode;
        public string ErrorTitle;
        public string WhatThisMeans;
        public string WhatYouCanDo;
        public string HelpLink;
        public string Type;
    }

    static public class ErrorDictionary
    {
        static ErrorDictionary()
        {
            LoadErrorsFromFile();
        }

        static public List<ErrorDetails> Errors { get; set; }

        static public void LoadErrorsFromFile()
        {
            //string path = "Resources/Raw/ErrorDetails.json";
            var stream = FileSystem.OpenAppPackageFileAsync("ErrorDetailsList.json").Result;

            var reader = new StreamReader(stream);

            var contents = reader.ReadToEnd();
           
                Errors = JsonConvert.DeserializeObject<List<ErrorDetails>>(contents);
            
        }

        static public ErrorDetails GetErrorDetails(string errorCode)
        {
            var error = Errors.FirstOrDefault(e => e.ErrorCode == errorCode);

            if (error == null)
            {
                ErrorDetails errorDetail = new ErrorDetails
                {
                    ErrorCode = "mnb-999",
                    ErrorTitle = "Error code not defined: " + errorCode,
                    WhatThisMeans = "The error code is not in the list of know error codes. More than likely this is a developer problem and will be resolved with an upcoming release",
                    WhatYouCanDo = "If you have opted in to share analytics and errors with the developer data related to this situation will be provided to the developer so that they can provide a fix with a future release",
                    HelpLink = "http://helpsite.com/error1"
                };
            }
            return error;
        }
    }

  
}

Custom Control Class

In the custom control class you are basically creating the bindable properties that are unique to your control. Here I have created bindable properties for all the ErrorDetails class properties plus properties that are unique to that particular error and are passed in from the viewmodel. The ErrorReason and ErrorMessage property along with the ErrorCode are passed into the popup through bindable properties in the viewmodel.

Learning: I discovered that I needed to add the OnPropertyChanged method to the properties. This is not shown in the Microsoft learn documentation. In the OnErrorCodeChanged method is where I lookup the error code from the Error Dictionary and then set the values of the control from the dictionary.

C#
using CommunityToolkit.Maui.Views;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

using DevExpress.Maui.Charts;
using DevExpress.Maui.Controls;
using DevExpress.Maui.Core.Internal;
using DevExpress.Utils.Filtering.Internal;

using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Controls;

using MyNextBook.Helpers;
using MyNextBook.Models;

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MyNextBook.CustomControls
{
    public partial class ErrorPopupView : ContentView, INotifyPropertyChanged
    {

        public static readonly BindableProperty ErrorTitleProperty = BindableProperty.Create(nameof(ErrorTitle), typeof(string), typeof(ErrorPopupView), propertyChanged: OnErrorTitleChanged);

        public static readonly BindableProperty ShowErrorPopupProperty = BindableProperty.Create(nameof(ShowErrorPopup), typeof(bool), typeof(ErrorPopupView), propertyChanged: OnShowErrorPopupChanged);
        public static readonly BindableProperty ErrorMessageProperty = BindableProperty.Create(nameof(ErrorMessage), typeof(string), typeof(ErrorPopupView), propertyChanged: OnErrorMessageChanged);
        public static readonly BindableProperty ErrorCodeProperty = BindableProperty.Create(nameof(ErrorCode), typeof(string), typeof(ErrorPopupView), propertyChanged: OnErrorCodeChanged);
        public static readonly BindableProperty ErrorReasonProperty = BindableProperty.Create(nameof(ErrorReason), typeof(string), typeof(ErrorPopupView), propertyChanged: OnErrorReasonChanged);
        public static readonly BindableProperty WhatThisMeansProperty = BindableProperty.Create(nameof(WhatThisMeans), typeof(string), typeof(ErrorPopupView), propertyChanged: OnWhatThisMeansChanged);
        public static readonly BindableProperty WhatYouCanDoProperty = BindableProperty.Create(nameof(WhatYouCanDo), typeof(string), typeof(ErrorPopupView), propertyChanged: OnWhatYouCanDoChanged);
        public static readonly BindableProperty HelpLinkProperty = BindableProperty.Create(nameof(HelpLink), typeof(string), typeof(ErrorPopupView), propertyChanged: OnHelpLinkChanged);
        public static readonly BindableProperty ErrorTypeProperty = BindableProperty.Create(nameof(ErrorType), typeof(string), typeof(ErrorPopupView), propertyChanged: OnErrorTypeChanged);
        public bool ShowInfo { get; set; } = false;
        public bool ShowErrorCode { get; set; } = true;
        public string ErrorType { get; set; }
        public string ExpanderIcon { get; set; } = IconFont.ChevronDown;
        public bool ErrorMoreExpanded { get; set; } = false;
        [RelayCommand] void ClosePopUp() => ShowErrorPopup = false;
        [RelayCommand]
        void ToggleErrorMore()
        {
            ExpanderIcon = (ExpanderIcon == IconFont.ChevronDown) ? IconFont.ChevronUp : IconFont.ChevronDown;
            ErrorMoreExpanded = !ErrorMoreExpanded;
            OnPropertyChanged(nameof(ErrorMoreExpanded));
            OnPropertyChanged(nameof(ExpanderIcon));

        }

        private void Popup_Closed(object sender, EventArgs e)
        {
            ErrorHandler.AddLog("do something here");
        }


        [RelayCommand]
        public void OpenHelpLink(string url)
        {
            if (url != null)
            {
                Launcher.OpenAsync(new Uri(url));
            }
        }
        public string ErrorTitle
        {
            get => (string)GetValue(ErrorTitleProperty);
            set => SetValue(ErrorTitleProperty, value);
        }


        private static void OnErrorTypeChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.ErrorType = (string)newValue;
            control.OnPropertyChanged(nameof(ErrorType));
            switch (control.ErrorType)
            {
                case "Info":
                    control.ShowErrorCode = false;
                    control.ShowInfo = true;
                    break;
                case "Error":
                    control.ShowErrorCode = true;
                    control.ShowInfo = false;
                    break;
                default:
                    control.ShowErrorCode = true;
                    control.ShowInfo = false;
                    break;
            }


        }
        private static void OnErrorTitleChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.ErrorTitle = (string)newValue;
            control.OnPropertyChanged(nameof(ErrorTitle));
        }
        public bool ShowErrorPopup
        {
            get => (bool)GetValue(ShowErrorPopupProperty);
            set => SetValue(ShowErrorPopupProperty, value);
        }

        private static void OnShowErrorPopupChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.ShowErrorPopup = (bool)newValue;
            control.OnPropertyChanged(nameof(ShowErrorPopup));
            //ErrorHandler.AddLog("ErrorPopupView ShowErrorPopup: " + newValue);
        }

        public string ErrorMessage
        {
            get => (string)GetValue(ErrorMessageProperty);
            set => SetValue(ErrorMessageProperty, value);
        }

        private static void OnErrorMessageChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.ErrorMessage = (string)newValue;
            control.OnPropertyChanged(nameof(ErrorMessage));
        }

        public string ErrorCode
        {
            get => (string)GetValue(ErrorCodeProperty);
            set => SetValue(ErrorCodeProperty, value);
        }

        private static void OnErrorCodeChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.ErrorCode = (string)newValue;
            control.OnPropertyChanged(nameof(ErrorCode));
            ErrorDetails ed = ErrorDictionary.GetErrorDetails(control.ErrorCode);
            if (ed != null)
            {
                control.ErrorTitle = ed.ErrorTitle;
                control.WhatThisMeans = ed.WhatThisMeans;
                control.WhatYouCanDo = ed.WhatYouCanDo;
                control.HelpLink = ed.HelpLink;
                control.ErrorType = ed.Type;
                control.OnPropertyChanged(nameof(ErrorTitle));
                control.OnPropertyChanged(nameof(WhatThisMeans));
                control.OnPropertyChanged(nameof(WhatYouCanDo));
                control.OnPropertyChanged(nameof(HelpLink));
                control.OnPropertyChanged(nameof (ErrorType));
                switch (control.ErrorType)
                {
                    case "Info":
                        control.ShowErrorCode = false;
                        control.ShowInfo = true;
                        break;
                    case "Error":
                        control.ShowErrorCode = true;
                        control.ShowInfo = false;
                        break;
                    default:
                        control.ShowErrorCode = true;
                        control.ShowInfo = false;
                        break;
                }
            }
        }

        public string ErrorReason
        {
            get => (string)GetValue(ErrorReasonProperty);
            set => SetValue(ErrorReasonProperty, value);
        }

        private static void OnErrorReasonChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.ErrorReason = (string)newValue;
            control.OnPropertyChanged(nameof(ErrorReason));
        }

        public string WhatThisMeans
        {
            get => (string)GetValue(WhatThisMeansProperty);
            set => SetValue(WhatThisMeansProperty, value);
        }

        private static void OnWhatThisMeansChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.WhatThisMeans = (string)newValue;
            control.OnPropertyChanged(nameof(WhatThisMeans));
        }

        public string WhatYouCanDo
        {
            get => (string)GetValue(WhatYouCanDoProperty);
            set => SetValue(WhatYouCanDoProperty, value);
        }

        private static void OnWhatYouCanDoChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.WhatYouCanDo = (string)newValue;
            control.OnPropertyChanged(nameof(WhatYouCanDo));
        }

        public string HelpLink
        {
            get => (string)GetValue(HelpLinkProperty);
            set => SetValue(HelpLinkProperty, value);
        }

        private static void OnHelpLinkChanged(BindableObject bindable, object oldValue, object newValue)
        {
            var control = (ErrorPopupView)bindable;
            control.HelpLink = (string)newValue;
            control.OnPropertyChanged(nameof(HelpLink));
        }
    }


}

Custom Control Template

The visualation for the custom control is contained within a resource dictionary. I created a file in the resources folder under styles and added a line in App.XAML to reference this newly added XAML file so that the standard custom controls I develop are available app wide and are stored in the application resource dictionary.

JavaScript
<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
            <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
            <ResourceDictionary Source="Resources/Styles/ControlTemplates.xaml" />

        </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>

</Application.Resources>

I have chosen to use the DevExpress DXPopup control for my visualization. From this point you just create your popup visual like you would any other XAML content. The one thing that took me a minute to figure out is that the ShowPopup binding needed to be twoway so that you could actually close the popup. The other is note the use of TemplateBinding in binding vs just the use of binding.

JavaScript
<ResourceDictionary
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:dx="clr-namespace:DevExpress.Maui.Core;assembly=DevExpress.Maui.Core"
    xmlns:dxco="clr-namespace:DevExpress.Maui.Controls;assembly=DevExpress.Maui.Controls"
    xmlns:markups="clr-namespace:OnScreenSizeMarkup.Maui;assembly=OnScreenSizeMarkup.Maui"
    xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit">
    <ControlTemplate x:Key="SimilarSeries" />
    <ControlTemplate x:Key="ErrorPopupStandard">

        <dxco:DXPopup
            x:Name="ErrorPopup"
            AllowScrim="False"
            Background="White"
            BackgroundColor="White"
            CornerRadius="20"
            IsOpen="{TemplateBinding ShowErrorPopup,
                                     Mode=TwoWay}">
            <Grid
                BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                HeightRequest="500"
                RowDefinitions="60,*,50"
                WidthRequest="300">


                <Border
                    Grid.Row="0"
                    Margin="6,5,5,15"
                    BackgroundColor="{dx:ThemeColor ErrorContainer}"
                    HorizontalOptions="FillAndExpand"
                    IsVisible="{TemplateBinding ShowErrorCode}"
                    Stroke="{dx:ThemeColor Outline}"
                    StrokeShape="RoundRectangle 30"
                    StrokeThickness="3">
                    <Label
                        Margin="0,4,0,4"
                        BackgroundColor="{dx:ThemeColor ErrorContainer}"
                        HorizontalOptions="Center"
                        Text="{TemplateBinding ErrorTitle}"
                        TextColor="{dx:ThemeColor Error}" />
                </Border>
                <Border
                    Grid.Row="0"
                    Margin="6,5,5,15"
                    BackgroundColor="{dx:ThemeColor ErrorContainer}"
                    HorizontalOptions="FillAndExpand"
                    IsVisible="{TemplateBinding ShowInfo}"
                    Stroke="{dx:ThemeColor Outline}"
                    StrokeShape="RoundRectangle 15"
                    StrokeThickness="3">
                    <Label
                        Margin="0,4,0,4"
                        BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                        HorizontalOptions="Center"
                        Text="{TemplateBinding ErrorTitle}"
                        TextColor="{dx:ThemeColor OnSecondaryContainer}" />
                </Border>
                <ScrollView Grid.Row="1" Margin="0,0,0,10">
                    <VerticalStackLayout Margin="6,0,4,10">
                        <Label
                            Margin="1,0,0,1"
                            BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                            HorizontalOptions="Start"
                            IsVisible="{TemplateBinding ShowErrorCode}"
                            LineBreakMode="WordWrap"
                            Text="{TemplateBinding ErrorCode,
                                                   StringFormat='Error Code: {0}'}"
                            TextColor="Black" />
                        <Label
                            Margin="1,0,0,5"
                            BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                            HorizontalOptions="Start"
                            LineBreakMode="WordWrap"
                            Text="{TemplateBinding ErrorReason}"
                            TextColor="{dx:ThemeColor OnErrorContainer}" />

                        <toolkit:Expander
                            x:Name="ErrorDetailExpander"
                            Margin="5,0,5,20"
                            IsExpanded="{TemplateBinding ErrorMoreExpanded}">
                            <toolkit:Expander.Header>
                                <VerticalStackLayout>

                                    <Label
                                        Margin="0,0,0,1"
                                        BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                        HorizontalOptions="Start"
                                        Text="What this means:"
                                        TextColor="Black" />
                                    <Label
                                        Margin="0,0,0,5"
                                        BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                        FontSize="Micro"
                                        HorizontalOptions="Start"
                                        LineBreakMode="WordWrap"
                                        Text="{TemplateBinding WhatThisMeans}"
                                        TextColor="Black" />
                                    <Label
                                        Margin="0,0,0,1"
                                        BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                        HorizontalOptions="Start"
                                        Text="What you can do:"
                                        TextColor="Black" />
                                    <Label
                                        Margin="0,0,0,5"
                                        BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                        FontSize="Micro"
                                        HorizontalOptions="Start"
                                        LineBreakMode="WordWrap"
                                        Text="{TemplateBinding WhatYouCanDo}"
                                        TextColor="Black" />
                                    <HorizontalStackLayout>
                                        <Label
                                            Margin="0,0"
                                            BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                            FontAttributes="Bold"
                                            FontSize="Small"
                                            Text="More Details"
                                            TextColor="{dx:ThemeColor OnPrimaryContainer}"
                                            VerticalOptions="Center" />
                                        <ImageButton
                                            Aspect="Center"
                                            BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                            Command="{TemplateBinding ToggleErrorMoreCommand}"
                                            CornerRadius="20"
                                            HeightRequest="38"
                                            HorizontalOptions="Center"
                                            VerticalOptions="Center"
                                            WidthRequest="38">
                                            <ImageButton.Source>
                                                <FontImageSource
                                                    x:Name="ErrorExpanderGlyph"
                                                    FontFamily="MD"
                                                    Glyph="{TemplateBinding ExpanderIcon}"
                                                    Size="24"
                                                    Color="{dx:ThemeColor OnPrimaryContainer}" />
                                            </ImageButton.Source>
                                        </ImageButton>
                                    </HorizontalStackLayout>
                                </VerticalStackLayout>

                            </toolkit:Expander.Header>
                            <VerticalStackLayout>
                                <Label
                                    Margin="0,0,0,1"
                                    BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                    HorizontalOptions="Start"
                                    Text="Error Message"
                                    TextColor="Black" />
                                <Label
                                    BackgroundColor="{dx:ThemeColor SecondaryContainer}"
                                    FontSize="Micro"
                                    HorizontalOptions="Start"
                                    LineBreakMode="WordWrap"
                                    Text="{TemplateBinding ErrorMessage}"
                                    TextColor="Black" />
                            </VerticalStackLayout>

                        </toolkit:Expander>


                    </VerticalStackLayout>
                </ScrollView>
                <Button
                    Grid.Row="2"
                    Command="{TemplateBinding ClosePopUpCommand}"
                    Text="Close" />
            </Grid>
        </dxco:DXPopup>



    </ControlTemplate>

    <ControlTemplate x:Key="ShortBookDetailView">
        <VerticalStackLayout>
            <Label Text="{TemplateBinding BookTitle}" TextColor="Green" />
            <Border
                Margin="5,0,5,5"
                BackgroundColor="red"
                HorizontalOptions="FillAndExpand"
                Stroke="{dx:ThemeColor Outline}"
                StrokeShape="RoundRectangle 30"
                StrokeThickness="3">
                <Border.Shadow>
                    <Shadow
                        Brush="White"
                        Opacity=".25"
                        Radius="10"
                        Offset="10,5" />
                </Border.Shadow>
                <VerticalStackLayout>
                    <Label Text="Hello" TextColor="Black" />
                    <Label Text="{TemplateBinding BookTitle}" TextColor="Black" />

                </VerticalStackLayout>

            </Border>
        </VerticalStackLayout>
    </ControlTemplate>
</ResourceDictionary>

Showing The Popup

Your page content view XAML add the custom control. To show the error popup set the ShowErrorPopup property to true. Setting ErrorCode, ErrorMessage, and ErrorReason properties provide the specific information for the error popup to display along with the defined error information from the error dictionary.

JavaScript
<controls:ErrorPopupView
Grid.Row="0"
Grid.Column="0"
ControlTemplate="{StaticResource ErrorPopupStandard}"
ErrorCode="{Binding ErrorCode}"
ErrorMessage="{Binding ErrorMessage}"
ErrorReason="{Binding ErrorReason}"

HeightRequest="1"
ShowErrorPopup="{Binding ShowErrorPopup}"
WidthRequest="1" />

In your view model define the properties for the custom control. And then set the ShowErrorPopup and the ErrorCode when you encounter a handled or unhandled condition.

JavaScript
[ObservableProperty] private bool showErrorPopup;
[ObservableProperty] private string errorTitle;
[ObservableProperty] private string errorMessage;
[ObservableProperty] private string errorCode;
[ObservableProperty] private string errorReason;

...
     } catch (Exception ex)
    {
        ErrorCode = "MNB-000";
        ErrorReason = ex.Message;
        ErrorMessage = ex.ToString();
        ShowErrorPopup = true;
        ErrorHandler.AddError(ex);
    }

That wraps it up for this post.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
hCentive
United States United States
https://www.linkedin.com/in/bradychambers

Comments and Discussions

 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA23-May-24 8:25
professionalȘtefan-Mihai MOGA23-May-24 8:25 
PraiseRe: My vote of 5 Pin
bradyguy23-May-24 8:43
bradyguy23-May-24 8:43 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.