Click here to Skip to main content
15,885,366 members
Articles / Desktop Programming / MFC
Article

Bugreporter

Rate me:
Please Sign up or sign in to vote.
4.83/5 (42 votes)
27 Jan 200511 min read 142.2K   2.9K   107   59
A handy bug-tracking tool for software development companies.

Main Screen

Introduction

This is a bug-tracking application for use within a software company that most of the people in my company have been using for a couple of months now.

Background

I started this project as a way to keep track of our company's bugs and communicate back and forth between the testers and programmers on our LAN. We already had a web-based bug-tracking tool, but nobody was actually using it. Besides, I don't really like web-based software for this purpose. So I've worked on this over a few months' time, and I'm pretty proud of it. I know there're still a couple of bugs in it, and also some stuff that could be changed to make it more efficient (which I will discuss below in the appropriate section).

First of all, I'd foremost like to thank Chris Maunder and the other CPians who have worked on the MFC Grid Control. This control made things a lot easier for what I was trying to do. I thank Mingming Lu, the author of the 'Network renju Game' article for the code I got for dealing with sockets. I also thank Prateek Kaul, author of the 'System Tray Icons' article for the class that I use in my program for displaying the icon in the system tray. Last, but definitely not least, I'd like to thank the Marc Richarme, author of the 'EasySize'() article. His macros have been invaluable in the resizing of my dialogs.

This code was compiled using Visual Studio .NET 2003, using version 7.1 of the MFC, and works fine under Windows2000/XP.

(Important!!!) Please Read This First

This code uses an SQL Server database on our company's server. I have included a script (bugreporter.sql) to recreate all the tables for your own database. There is an entry in our database for '(unknown)' which happens to have a personid of 4 in our database. Some of the code actually references this constant value, that's why I've added to the script to add these people. So don't delete this record -- or change this value accordingly. There have also been values added to the tables 'tlkpreturnstatus' and 'tlkptype'. There are five values in the table 'tlkpSeverity'. These values correspond to the flag icons that show up in the grid (and there're only five of them).

I have not included Chris Maunder's MFC Grid source, because the graphics and icons I use for this project are big enough as it is. I also have not included the CSystemTray source files, or the EasySize source files. Besides making the download bigger, I think all these articles should be downloaded by the user so that I'm not redistributing another author's source code. Also, I think these are all good articles, so you should probably give them a look yourself!

If you run the demo, you will need to run the SQL script first in a database that you create on your SQL Server, with database, username, and passwords of 'bugreporter'. The demo was built as 'SHOWSERVERCONFIGRELEASE' so that you are allowed to input the name of your server. If you use this code for your company, then you could put the appropriate values in and use the regular 'RELEASE' version. But it will only ask you for the server name once either way, and stores the value in the registry. The first time you log in to BugReporter, you can use 'fp','sp', or 'tp' as login names.

I've used ADO in this application by using the #import directive. The path to this file may be different on your computer.

#import "C:\Program Files\Common Files\System\ado\msado15.dll" \
no_namespace \
rename("EOF", "adoEOF")

Using the code

Maintenance Screen

Once you have the application running, go to the 'Tools' menu, and select 'Maintenance' or click the gear icon on the toolbar. You will need to set up a project to put any bugs into the application. Once you add a new project, be sure to add a version number! Since the maintenance is probably going to be done by the programmers, I haven't included a lot of functionality to this screen. However, it should not allow you to delete a project or user if they're tied to a bug already. Also, since this app is used on our LAN, I use the 'computername' field to keep track of the users' computer name for sending alerts and chatting.

To add a new bug report, click on 'New'. This puts the user in 'NEW' state. (The 'state' is an enum property used for enabling/disabling of controls, and to determine how to save a bug if it is in 'EDIT' or 'NEW' mode.) Some type of description is required, as well as the 'Submitted By', 'Submitted To', and 'Version'.

To update a bug, (usually done by the person who submitted the bug in the first place), click the 'Edit' button. This puts you in 'EDIT' mode. You can now change any of the parameters of the bug submittal.

When a programmer wishes to respond to a bug, press 'Respond'. This will enable the 'Return Status Notes' edit box, and the 'Return Status' combo.

If there's a bug selected, you can add an attachment by dragging an item onto the application in any mode, or clicking the 'Plus' icon next to the attachments listbox in 'NEW' or 'EDIT' mode. When you do this, the actual bytes of the file are saved in the database, along with the filename and extension. To view an attachment, double-click on it in the listbox. The file is re-created from the database and saved in a folder called 'temp' in the same folder where BugReporter is located. I then use ShellExecute() to open the file with the default application. The files are marked as Read-Only so that if you open it and then forget it was opened from BugReporter, it won't allow you to save over the copy in the 'Temp' folder. This folder is cleaned out upon exiting BugReporter, so make sure you don't save anything else in this folder!! You can remove attachments from a bug by clicking the 'Minus' sign next to the listbox. Here's what that code looks like:

void CBugReporterView::OnLbnDblclkScaps()
{
    CString        url = _T("");
    int            index = m_lstScaps.GetCurSel();
    m_lstScaps.GetText(index, url);
    int            data = (int)m_lstScaps.GetItemData(index);
    CString        strMessage,strWhere,strDirectory;
    char         szPath[512];
    BYTE*        pBuf;
    BYTE*        pBytes;
    _variant_t   varBLOB;
    unsigned long nLength;
    try
    {
        BeginWaitCursor();
        _RecordsetPtr rst;

        strWhere.Format("LinkID = %d",data);
        rst = theApp.GetRecordset(_T("*"),_T("tblLink") ,strWhere);
        CString str,strPath;
        FieldsPtr pFields = rst->GetFields();
        FieldPtr pField,pName;
        pField= pFields->GetItem(_T("FileBytes"));
        pName = pFields->GetItem(_T("Link"));
        nLength = pField->GetActualSize();
        str.Format("%s",(char*)_bstr_t(pName->Value));
        varBLOB = pField->GetChunk(nLength);
        //pBuf = new BYTE[nLength];
        SafeArrayAccessData(varBLOB.parray,(void **)&pBuf);
        // Initialize the data member variable with the data.
        pBytes = pBuf;
        // Unlock the safe array data.
        SafeArrayUnaccessData(varBLOB.parray);
        ::GetModuleFileName(NULL,szPath,512);
        strPath.Format("%s",szPath);
        int pos = strPath.ReverseFind('\\');

        strDirectory = strPath.Left(pos + 1) + "temp\\";
        strPath = strDirectory + url;
        CreateDirectory(strDirectory,NULL);

        CFile file;
        CFileException ex;
        CFileStatus status;
        status.m_attribute = 0;

        //if file already exists in this directory, 
        //remove read-only attribute, then delete it
        if(file.Open(strPath,CFile::modeRead,&ex))
        {
            file.Close();
            CFile::SetStatus(strPath,status);
            DeleteFile(strPath);
        }

        //if we're able to create the file
        if(file.Open(strPath,CFile::modeCreate | CFile::modeWrite,&ex))
        {
            file.Write(pBytes,nLength);
            file.Close();
            file.GetStatus(status);
            status.m_attribute |= 0x01; //read only
            CFile::SetStatus(strPath,status);
            int iReturn = (int) ShellExecute(NULL, _T("open"), 
                          strPath, NULL, NULL, SW_SHOWNORMAL);

            // If ShellExecute returns an error code, let the user know.
            if (iReturn <= 32)
                MessageBox (_T("Cannot open file.  File may have" 
                  " been moved or deleted."), _T("Error!"), 
                  MB_OK | MB_ICONEXCLAMATION) ;

            if(url.GetLength()==0)
                MessageBox (_T("This is an empty entry!" 
                      " Try clicking a filled link! "), 
                      _T("Error!"), MB_OK | MB_ICONEXCLAMATION) ;
        }//if(file.Open(strPath,CFile::modeCreate | CFile::modeWrite,&ex))
        else        //file might have been opened already
        {
            AfxMessageBox("File could not be opened from this location.\r\n"
                "Check to make sure you don\'t already" 
                " have this attachment open.",MB_ICONINFORMATION);
        }
    }
    MYCATCHALL

    VariantClear(&varBLOB);
    url.ReleaseBuffer();
    EndWaitCursor();

}

After a bug has been marked as 'fixed' or 'irreproducible' for return status, it is deemed 'Ready to Test', which you can see by changing the current view with the combobox on the upper right of the application under 'Ready to Test'. If the submitter of the bug re-tests the bug and sees that it is resolved, they should mark it as such. If not, the user can add more text to the description and save. When saving, it will ask if you want to mark the bug as 'Return to Programmer'. If so, the bug will show up with its 'type' column highlighted in yellow so that it stands out to the programmer.

The 'Current View' combo is populated from all the types in the table 'tlkptype' in the database. I have included some default types in the SQL script to populate this table, as well as some for the 'Return Status'. (I think this is probably already too many, so you probably won't have to add anymore.) There are also hard-coded values that are added to the combobox, like 'Ready To Test', 'Mine', and have itemdata associated with them in the combobox to generate the appropriate SQL statement in the Refresh() procedure. These are the other types added to 'Current View' combobox, and the SQL generated from them:

switch(nFilter)
{
case 1000:
    //only the current user's bugs
    strWhere2.Format(_T(" AND dbo.tblBug."
             "SubmittedToID = %d"),theApp.m_nUserID );
    break;
case 1001:
    //only unresolved bugs
    strWhere2 = _T(" AND dbo.tblBug.Resolved = 0");
    break;
case 1002:
    //only unanswered bugs
    strWhere2 = _T(" AND dbo.tblBug.ReturnStatusID = 4");
    break;
case 1003:
    strWhere2 = _T(" AND dbo.tblBug.Resolved = 1");
    break;
case 1004:
    //all
    strWhere2 = _T("");
    break;
case 1005:
    //ready to test
    strWhere2 = _T(" AND ((dbo.tblBug.Resolved = 0 AND
           dbo.tblBug.ReturnStatusID = 2) OR
           (dbo.tblBug.Resolved = 0 AND
           dbo.tblBug.ReturnStatusID = 1))");
    break;
case 1006:
    //returned to programmer
    strWhere2 = _T(" AND dbo.tblBug.RTP = 1");
    break;
default:
    //otherwise, it's just a bug type id
    strWhere2.Format(_T(" AND dbo.tblBug.TypeID = %d"),nFilter);
}

After saving/editing/responding to a bug, you will also get a dialog (unless you have turned it off through the 'Options' screen) asking if you would like to send the appropriate person an alert. This will display a brief message box that will tell you if your alert was sent successfully or not. You may also send a user an alert at any time by clicking the button next to their name in 'Submitted By' or 'Submitted To' comboboxes. You cannot send an alert to yourself, or to 'UNKNOWN', or to any user with 'COMPUTER' set as their computername. When you receive an alert, you will receive a messagebox, and after clicking 'OK', you will be set to the appropriate bug.

Options Screen

There are several options you can configure here, and I think most of them are self-explanatory. Just make sure that you click 'Apply' after changing any options or they won't be saved.

Chat Screen

This screen populates all the users in your system except you, of course. Just select which user you want to chat with, and send them a message. Your message won't go through if the user doesn't have BugReporter running or if they have put themselves in 'Do not Disturb' mode (found on the 'Tools' menu and indicated by a checkmark). Every message sent opens and closes the chat socket, so you can chat with multiple people at once. If you're chatting with Person1 and receives a message from Person2, it puts Person2 in the SendTo combo so that your next messge goes to Person2, unless you're in the middle of typing. In that case, it won't switch the person, because I'd assumed that if you were already typing, then you want your current message to go to the person you've already selected. I implemented a homemade encryption algorithm to hide your text from sniffers. It's no Computer Science winner, it was just something I wanted to try. :)

Print Selected Bug

Chris Maunder's MFC Grid already takes care of the printing with or without the Doc/View architecture, but I added a way to print a single bug at a time so that you can see the entire 'Description', 'Response', and any attachments (the filenames are listed under 'Attachments:'). If your current view is 'Ready to Test', it will prompt you for a 'Version Number'. This is not necessary, it's just a way to display a text value on the report for the tester's reference.

Find Screen

The 'Find' Dialog allows you to put in different criteria to find previously submitted bugs. You can also print the list once you have one. It might not be that helpful, but it's free, since all I had to do was call the grid's Print() function! After you locate a bug, you can double-click it to view it on the main screen. This dialog is modeless, so you can switch back and forth as necessary.

Points of Interest

Again, using the MFC Grid's built-in functionality, I've allowed the user to save the current list as a comma-delimited text file, in case they want to export it to somewhere else. I've used a pretty generic error handler that writes out the errors in HTML format. I've defined this as MYCATCHALL in the stdafx.h file:

#define MYCATCHALL catch(CException* e) \
{ \
    m_pErrorHandler->HandleError(e,__FUNCTION__); \
} \
catch(_com_error e)  \
{ \
    m_pErrorHandler->HandleError(e,__FUNCTION__); \
} \
catch(...) \
{\
    m_pErrorHandler->HandleError(__FUNCTION__);\
}

Most of the dialogs have animation (which you can turn off in the 'Options' dialog). I made a function for the application class to handle this. Just send it the HWND of the dialog you want to animate, and it randomly picks one. I removed the Fade In/Out animation because controls weren't painting correctly.

void CBugReporterApp::AnimateDialog(HWND hWnd)
{
    DWORD        dwTemp2,dwTemp,dwAnimate = AW_ACTIVATE;
    int            nFirst,nSecond;

    try
    {
        //seed randomNumber Generator
        srand( (unsigned)time( NULL ) );
        nFirst = rand() % 3 + 1;

        switch(nFirst)
        {
            case 1: dwTemp = AW_SLIDE; break;
            case 2:
               //removed because it was AW_BLEND which 
               //doesn't seem to repaint controls correctly
            case 3: dwTemp = AW_CENTER; break;
        }

        dwAnimate |= dwTemp;

        //these next values will only work with AW_SLIDE
        if(nFirst == 1)
        {
            srand( (unsigned)time( NULL ) );
            nSecond = rand() % 4 + 1;

            switch(nSecond)
            {
                case 1: dwTemp2 = AW_HOR_POSITIVE;  break;
                case 2: dwTemp2 = AW_HOR_NEGATIVE;  break;
                case 3: dwTemp2 = AW_VER_POSITIVE;  break;
                default:dwTemp2 = AW_VER_NEGATIVE;
            }
            dwAnimate |= dwTemp2;

            srand( (unsigned)time( NULL ) );
            nSecond = rand() % 2;

            if(dwTemp2 == AW_VER_POSITIVE || dwTemp2 == AW_VER_NEGATIVE)
            {
                dwTemp2 = 0;
                switch(nSecond)
                {
                    case 1: dwTemp2 = AW_HOR_POSITIVE; break;
                    case 2: dwTemp2 = AW_HOR_NEGATIVE; break;
                }
                dwAnimate |= dwTemp2;
            }//if(dwTemp2 == AW_VER_POSITIVE || dwTemp2 == AW_VER_NEGATIVE)
            else
            {
                dwTemp2 = 0;
                switch(nSecond)
                {
                    case 1: dwTemp2 = AW_VER_POSITIVE; break;
                    case 2: dwTemp2 = AW_VER_NEGATIVE; break;
                }
                dwAnimate |= dwTemp2;
            }



        }//if(nFirst == 1)

        ::AnimateWindow(hWnd,200,dwAnimate);
    }
    MYCATCHALL

}

There are also a few helper functions that I use for dealing with recordsets and comboboxes.

void CBugReporterApp::CloseRecordset(_RecordsetPtr rst)
int CBugReporterApp::GetIntField(CString strSQL)
void CBugReporterApp::FillCombo(CString strTable, CString strDataField, 
     CString strDisplayField, CComboBox& cbo, 
     CString strAlias,BOOL bAddEmpty)
CString CBugReporterApp::GetStringField(CString strField, 
                      CString strTable, CString strWhere)
void CBugReporterApp::ExecuteSQL(CString strSQL)
void CBugReporterApp::SetComboFromID(CComboBox& cbo, int nID)
void CBugReporterApp::EmptyCombo(CComboBox& cbo)
_RecordsetPtr CBugReporterApp::GetRecordset(CString strFields, 
                           CString strTable, CString strWhere)

The 'Zoom' dialog, the main app window, and most others have a value that will be saved in the registry for their placement so that their positions and sizes are persistent. I took this from an example I found somewhere. This is what it looks like for the Chat dialog:

void CChatDlg::OnDestroy()
{
    CDialog::OnDestroy();
    WINDOWPLACEMENT wp;
    GetWindowPlacement(&wp);
    theApp.WriteProfileBinary("Settings", "ChatWindowPos", 
                                  (LPBYTE)&wp,sizeof(wp));
}

I made the toolbar buttons bigger with (I think) better icons.

//create an HBITMAP for the image
HBITMAP hbm = (HBITMAP)::LoadImage(AfxGetInstanceHandle(),
         MAKEINTRESOURCE(IDB_BITMAP1), IMAGE_BITMAP, 0,0, // cx, cy
LR_CREATEDIBSECTION | LR_LOADMAP3DCOLORS );

//make a CBitmap and attach HBITMAP to it
CBitmap bm;
bm.Attach(hbm);


//create imagelist and specify 256 colors
int ret = m_ilToolBar.Create(32,32,ILC_COLOR8 | ILC_MASK,1,0);
ret = m_ilToolBar.Add(&bm,RGB(0,0,0));
//The second parameter sets the gray background color to transparent

m_wndToolBar.GetToolBarCtrl().SetImageList(&m_ilToolBar);

//make toolbar buttons the appropriate size
SIZE szButton,szImage;
szButton.cx = 39; //button width must be 7 pixels greater than image
szButton.cy = 38; //height must be 6 pixels greater than image
szImage.cx = 32;
szImage.cy = 32;
m_wndToolBar.SetSizes(szButton,szImage);

//should be called after any resizing is done to buttons or images
m_wndToolBar.GetToolBarCtrl().AutoSize();

I also added a popup menu for when you right-click on the grid in the main window. You can either delete the selected bug(s), or change the project for the selected bug(s). Once you open the popup menu, the secondary menu for the projects shows all the projects except the current one. This is done dynamically every time you view the menu and is implemented in the subclassed grid control I made for the main window, called CMyGridCtrl:

void CMyGridCtrl::OnRButtonUp(UINT nFlags, CPoint point)
{
    if(this->GetRowCount() > 1)
    {
        _RecordsetPtr    rst;
        CString            strSQL;
        CPoint            mypoint(point);

        ClientToScreen(&mypoint);
        //convert to screen coordinates to get point to display popupmenu

        CMenu* pMenu = this->pPopupMenu->GetSubMenu(0);

        CMenu* pMenu2 = pMenu->GetSubMenu(0);
        //remove all items from the 'Projects' submenu
        while(pMenu2->RemoveMenu(0,MF_BYPOSITION));


        strSQL.Format(_T("SELECT ProjectID,Project FROM tblProject" 
              " WHERE ProjectID <> %d"),this->m_pView->m_nCurrentProjectID);
        VARIANT* var = NULL;
        rst.CreateInstance(__uuidof (Recordset));
        rst->CursorLocation = adUseClient;
        rst = theApp.g_Cnn->Execute(strSQL.AllocSysString(),var,0L);
        FieldsPtr pFields;
        FieldPtr pID,pName;

        if(!rst->adoEOF)
            pFields = rst->Fields;

        int nStatusID = 0;
        CString strStatus;

        //loop through and get all the projects except 
        //the current one, then add them to submenu
        while(!(rst->adoEOF))
        {
            pID = pFields->GetItem(_T("ProjectID"));
            pName = pFields->GetItem(_T("Project"));
            nStatusID = (int)pID->Value;
            strStatus.Format("%s",(char*)_bstr_t(pName->Value));
            pMenu2->AppendMenu(MF_STRING,nStatusID,strStatus);
            rst->MoveNext();
        }

        pMenu->TrackPopupMenu(TPM_LEFTALIGN | TPM_RIGHTBUTTON, 
                                    mypoint.x,mypoint.y,this);
        theApp.CloseRecordset(rst);
    }
    CGridCtrl::OnRButtonUp(nFlags, point);
}

The magnifying glass buttons zoom you in so you you have more viewing space when viewing/editing bugs. The last project, bug, view are all saved in the registry so that the application puts you right back where you were when starting the app again. 'BUGID' shows up in the grid so that when printing out a list, you can easily identify a bug by its number if the description is too short.

Known Problems

  • If app is open overnight, it sometimes loses connection and has to be restarted.
  • Should use CString variable when sending chat messages.
  • My copy of the MFC Grid Control still had a bug in the EnsureVisible() function, see the fix I implemented here. If this bug is still in the source when you compile it yourself, it will crash when calling EnsureVisible() in BugReporter's Refresh() function if the grid doesn't have focus.

Closing

I hope this app helps anybody and really hope you rate it. I'm pretty sure there are probably other bugs that I haven't found yet, but I'd be happy to know about them and will change the code accordingly. Whether you like the article or not, please rate it. If you don't like it and give the article a low rating, I hope that you'll also leave a comment so that I know why.

Feel free to use this code in any way you like. Any additions and/or changes are welcome.

History

  • Version 4.2 - Posted 01/26/2005.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here


Written By
Web Developer
United States United States
Started off with vb6 Smile | :) and am self-taught at C++, FoxPro, DirectX, etc. I'm currently developing with VFP9 and C# in desktop apps, webservices, telephony apps.

Comments and Discussions

 
GeneralRe: Converted VC++6 Project Pin
BlackDice4-Feb-05 2:37
BlackDice4-Feb-05 2:37 
GeneralRe: Converted VC++6 Project Pin
Mark Chuang29-Mar-05 20:08
Mark Chuang29-Mar-05 20:08 
GeneralRe: Converted VC++6 Project Pin
BlackDice4-Apr-05 5:36
BlackDice4-Apr-05 5:36 
GeneralRe: Converted VC++6 Project Pin
Nipun29-Mar-05 21:46
Nipun29-Mar-05 21:46 
GeneralRe: Converted VC++6 Project Pin
Wrangly13-Apr-05 23:44
Wrangly13-Apr-05 23:44 
GeneralRe: Converted VC++6 Project Pin
Aqualic14-Apr-05 14:49
Aqualic14-Apr-05 14:49 
GeneralRe: Converted VC++6 Project Pin
Wrangly14-Apr-05 20:55
Wrangly14-Apr-05 20:55 
GeneralRe: Converted VC++6 Project Pin
Aqualic14-Apr-05 21:04
Aqualic14-Apr-05 21:04 
GeneralRe: Converted VC++6 Project Pin
Wrangly14-Apr-05 21:23
Wrangly14-Apr-05 21:23 
GeneralRe: Converted VC++6 Project Pin
Mark Chuang2-May-05 3:42
Mark Chuang2-May-05 3:42 
Generalproject can't compile Pin
JimmyO29-Jan-05 0:21
JimmyO29-Jan-05 0:21 
GeneralRe: project can't compile Pin
Aqualic30-Jan-05 20:28
Aqualic30-Jan-05 20:28 
GeneralSuggestion Pin
Eduardo Calixto28-Jan-05 5:32
Eduardo Calixto28-Jan-05 5:32 
GeneralRe: Suggestion Pin
BlackDice28-Jan-05 5:39
BlackDice28-Jan-05 5:39 
General#import Pin
Jörgen Sigvardsson27-Jan-05 9:49
Jörgen Sigvardsson27-Jan-05 9:49 
GeneralRe: #import Pin
BlackDice27-Jan-05 9:50
BlackDice27-Jan-05 9:50 
GeneralRe: #import with LIBIDs and PC-Lint - a tip Pin
Anna-Jayne Metcalfe29-Jan-05 7:43
Anna-Jayne Metcalfe29-Jan-05 7:43 
GeneralRe: #import Pin
Alex Evans30-Jan-05 9:22
Alex Evans30-Jan-05 9:22 
GeneralRe: #import Pin
Jörgen Sigvardsson30-Jan-05 9:24
Jörgen Sigvardsson30-Jan-05 9:24 
GeneralRe: #import Pin
Alex Evans30-Jan-05 9:35
Alex Evans30-Jan-05 9:35 
GeneralRe: #import Pin
Jörgen Sigvardsson30-Jan-05 9:43
Jörgen Sigvardsson30-Jan-05 9:43 
GeneralRe: #import Pin
Alex Evans30-Jan-05 9:49
Alex Evans30-Jan-05 9:49 
GeneralRe: #import Pin
Alex Evans30-Jan-05 10:16
Alex Evans30-Jan-05 10:16 
GeneralRe: #import Pin
Jörgen Sigvardsson30-Jan-05 10:20
Jörgen Sigvardsson30-Jan-05 10:20 
GeneralRe: #import Pin
Alex Evans30-Jan-05 10:28
Alex Evans30-Jan-05 10:28 

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.