Chapter 8. Forums

Internet users like to feel part of a community of people with similar interests. A successful site should build a community of loyal visitors, providing a place where they can discuss their favorite subjects, ask questions, and reply to others. Community members return often to talk to other people with whom they've already shared messages, or to find comments and opinions about their interests. This chapter outlines some of the advantages of building such a virtual community, its goals, and the design and implementation of a new module for setting up and managing discussion boards.

Problem

User-to-user communication is important in many types of sites. For example, in a content site for pub enthusiasts, visitors to the site may want advice about the best way to brew their own beer, suggestions for good pubs in their area, to share comments on the last event they attended, and so on. Having contact with their peers is important so that they can ask questions and share their own knowledge. E-commerce sites have an added benefit of enabling users to review products online. Two ways to provide user-to-user communication are opinion polls and discussion boards. We've already looked at opinion polls in Chapter 6, and in this chapter we'll look at discussion boards, also known as forums. Visitors can browse the various messages in the forums, post their questions and topics, reply to other people's questions, and share ideas and tips. Forums act as a source of content, and provide an opportunity for users to participate and contribute. One reason why forums are especially attractive from a manager's perspective is that they require very little time and effort from employees because end users provide most of the content. However, a few minutes a day should be spent to ensure that nobody has posted any offensive messages, and that any problems that may be mentioned in a message receive some attention (maybe problems with the site or questions about products, locations, etc.).

As for TheBeerHouse site, we will offer discussion boards about brewing beers, pubs, concerts and parties, and more. These will be separate forums, used to group and categorize the threads by topic, so that it's easier for visitors to read what they are interested in. Early web forum systems often threw up long lists of messages on a single page, which took ages to load. This can be avoided by displaying lists in pages, with each page containing a particular number of messages. The website already has a way to identify users, and the forums will need to be integrated with that membership system. Besides being identified by username in the forum module, users may like something "catchy" in order to be recognized by the community: something such as an avatar image (a small picture that represents the user on their messages) and a signature line. This information will be added to every post and will help readers quickly identify the post's author. Of course, as with any other module you've developed so far, the site's administrators and editors must be able to add, remove, or edit forums, topics, and replies.

Design

Before looking at the design, let's consider a more accurate list of features to be implemented:

  • Support for multiple categories, or subforums, that are more or less specific to a single topic/argument. Subforums are identified by name and description, and optionally by an image.

  • Forums must support moderation. When a forum is moderated (this is a forum-level option), all messages posted by anyone except power users (administrators, editors, and moderators) are not immediately visible on the forum but must be approved by a member of one of the power user roles first. This is a useful option to ensure that posts are pertinent, not offensive, and comply with the forum's policy. However, this also places a bigger burden on the power users because posts have to be approved often (at least several times a day, even on weekends), or users will lose interest. Because of the timeliness needed for moderation, most forums are not moderated, but they are checked at least once a day to ensure that their policies have not been violated (with no particular need to check on weekends).

  • The list of threads for a subforum, and the list of posts for a thread, must be paginable. In addition, the list of threads must be sortable by the last posting date, or the number of replies or views, in ascending or descending order. Sort options are very helpful if there are a lot of messages.

  • Posting is only permitted by registered members, whereas browsing is allowed by everybody. An extension of the forum implemented in this chapter may include more options to specify that browsing also requires login or that posting is allowed by anonymous users.

  • Users will be able to format their messages with a limited, and safe, set of HTML tags. This will be done by means of the FCKeditor control already used in Chapter 5, with a reduced toolbar.

  • While creating a new thread, a user must be able to immediately close the thread so that other users cannot reply. If replies are allowed, they can later be disabled (and thus the thread closed) only by administrators, editors, and moderators.

  • Users can modify their own posts anytime, but the operation must be logged in the message itself (a simple note in the message saying that it was edited will avoid confusion if another user remembers seeing something in a message but the next time they look it's gone).

  • Users can have an avatar image associated with their account, which is displayed on every post they make. This helps them create a virtual, digital identity among other users of the forum. Users can also define a signature to be automatically added at the end of each post, so that it doesn't need to be copied and pasted every time. Signatures often include a special greeting, motto, old saying, or any other quote taken from a movie, taken from a famous person, or coined by the member herself. Sometimes it will contain a URL of that person's own site — this is normally OK, but you probably don't want any kind of advertising in this manner (e.g., www.BuyMyProduct.com).

  • The messages posted by users are counted, and the count is displayed, together with the user's avatar, on each of the user's messages. This count is a form of recognition, and it lets other users know that this person might be more knowledgeable, or at least that they've hung around in the forums a lot (it tends to lend them more credibility). In addition, the forum system supports three special user levels; these are descriptions tied to the number of posts each user has made. The number of posts needed to advance to the next level can be configured, just like the descriptions.

  • Full support for RSS 2.0 feeds should allow users to track new messages in an RSS aggregator program (such as the free SharpReader or RSS Bandit). There will be a flexible syndication system that provides distinct feeds to specific subforums, or all forums, and it will sort posts in different ways. This enables users to get a feed with the 10 newest threads posted into any forum, or with the 10 most popular threads (if sorted by number of replies). The feeds will be consumed by the generic RSS Reader control already developed in Chapter 5.

  • Administrators, editors, and moderators can edit and delete any post. Additionally, they can move an entire thread to a different forum, which is helpful for ensuring that all threads are published in the appropriate place.

Note

Remember that you need some kind of policy statement somewhere in the forum pages that tells users what the rules are. This is usually needed for legal reasons in case a nasty, hateful, or untruthful message is posted and not caught quickly — just some kind of disclaimer to protect the site owners/administrators from lawsuits.

Designing the Database Tables

Since the last version of the book only one modification has been made to the Forum tables: a Sticky field was added to the tbh_Post table. This was added to allow administrators to make a post stick to the top of the list on the first page of the forum. The Active, DateUpdated, and UpdatedBy fields were also added. Figure 8-1 represents the tables as shown by the Database Diagram Editor window of VS 2008's Server Explorer tool (which is similar to what Enterprise Manager would show in SQL Server 2005).

Figure 8-1

Figure 8.1. Figure 8-1

The tbh_Forums table is similar to the tbh_Categories table used in Chapter 5 for the articles module, with the addition of the Moderated column, which indicates whether the messages posted by normal users must be approved before they become visible in the forums' pages. The tbh_Posts table contains the following columns (the usual AddedDate and AddedBy fields aren't shown here):

  • PostID: The primary key.

  • AddedByIP: The IP of the user who authored the message — used for auditing purposes. Remember that you may become partially responsible for what users write (this also depends on the laws of your country). You should try to log information about the user who posted a message (such as the date/time and IP address), so you can provide this to legal authorities in the unlikely event that it might be needed.

  • ForumID: The foreign key to a parent forum.

  • ParentPostID: An integer referencing another record in the same table, which is the first message of a thread. When this field contains 0, it means that the post has no parent post; therefore, this is a thread post. Otherwise, this is a reply to an existing thread.

  • Title: The title of the post. Reply posts also have a title; it will usually be "Re: {thread title here}", but it's not absolutely necessary and the user will be free to change it while posting a new reply.

  • Body: The body of the post, in HTML format (only limited HTML tags are allowed).

  • Approved: A Boolean value indicating whether the post has been approved by a power user (administrators, editors, and moderators), and visible on the end-user pages. If the parent forum is not moderated, this field is automatically set to 1 when the post is created.

  • Sticky: This field is only used for thread posts, and is a Boolean value indicating whether the thread is to always be listed at the top of the thread list for the forum. Administrators will have the ability to turn this feature on an off.

  • Closed: This field is only used for thread posts and is a Boolean value indicating whether the thread is closed and no more replies can be added. The user will be able to specify this option only while creating the thread. Once a thread has been created, only power users can close the thread.

  • ViewCount: An integer indicating the number of times a thread has been read. If the record represents a reply, this field will contain 0.

  • ReplyCount: The number of replies for the thread post. If the record represents a reply, this field will contain 0.

  • LastPostBy: The name of the member who submitted the last post to this thread. As long as there are no replies, the field contains the name of the member who created the thread, which is also the name stored in the record's AddedBy field.

  • LastPostDate: The date and time of the last post to this thread. As long as there are no replies, the field contains the date and time when the thread was created, which is also the value stored in the record's AddedDate field.

In the case of ParentPostID, the replies will always link to the first post of the thread, not to another reply. Therefore, the proposed structure does not support threaded discussions, such as those in Internet newsgroups. Instead, posts of nonthreaded discussions will be shown to the reader, sorted by creation date, from the oldest to the newest, so that they are read in chronological order. Both of these two types of forum systems, threaded or not, have their pros and cons. Threaded discussions make it easier to follow replies to previous posts, but nonthreaded discussions make it easier to follow the discussion with the correct temporal order (time-sequenced). To make it easier for the reader to follow the discussion, nonthreaded discussions usually allow users to quote a previous reply, even if the referenced reply is a number of posts prior to that one. In my research, nonthreaded discussions are more widely used, and easier to develop, so we'll use them for our sample site. If you want to modify the forum system to support threaded discussions, you'll be able to do that without modifying the DB; you just need to set the post's ParentPostID to the appropriate value.

Designing the Configuration Module

The configuration settings of the forums module are defined in a <forums> element within the <theBeerHouse> section of the web.config file. The class that maps the settings and exposes them is ForumsElement, which defines the properties in the following table.

Property

Description

ProviderType

The full name (namespace plus class name) of the concrete provider class that implements the data access code for a specific data store.

ConnectionStringName

The name of the entry in web.config's new <connectionStrings> section containing the connection string for this module's database.

EnableCaching

A Boolean value indicating whether caching is enabled.

CacheDuration

The number of seconds for which the data is cached if there aren't any inserts, deletes, or updates that invalidate the cache.

ThreadsPageSize

The number of threads listed per page when browsing the threads of a subforum.

PostsPageSize

The number of posts listed per page when reading a thread.

RssItems

The number of threads included in the RSS feeds.

HotThreadPosts

The number of posts that make a thread hot. Hot threads will be rendered with a special icon to be distinguished from the others.

BronzePosterPosts

The number of posts that the user must reach to earn the status description defined by BronzePosterDescription.

BronzePosterDescription

The title that the user earns after reaching the number of posts defined by BronzePosterPosts.

SilverPosterPosts

The number of posts that the user must reach to earn the status description defined by SilverPosterDescription.

SilverPosterDescription

The title that the user earns after reaching the number of posts defined by SilverPosterPosts.

GoldPosterPosts

The number of posts that the user must reach to earn the status description defined by GoldPosterDescription.

GoldPosterDescription

The title that the user earns after reaching the number of posts defined by GoldPosterPosts.

Designing the Business Layer

The design of the core business layer follows the same rules as the previous modules, a set of entity model classes extended through the partial class functionality and a set of corresponding repository classes. Figure 8-2 is a class diagram of the Forum module Business classes.

Figure 8-2

Figure 8.2. Figure 8-2

Designing the User Interface Services

The last thing we need to define are the UI pages and user controls that enable the user to browse forums and threads, post new messages, and administer the forum's content. Following is a list of the user interface pieces that you'll develop shortly in the "Solution" section:

  • ~/Admin/ManageForums.aspx: Lists forums and allows deletes.

  • ~/Admin/AddEditForums.aspx: Adds, updates, and deletes a designated forum.

  • ~/Admin/ManageUnapprovedPosts.aspx: Lists all unapproved posts (first thread posts and then replies, all sorted from the oldest to the newest), shows the entire content of a selected post, and approves or deletes it.

  • ~/Admin/MoveThread.aspx: Moves a thread (i.e., the thread post and all its replies) to another forum.

  • ~/ShowForums.aspx: Shows the list of all subforums, with their title, description, and image. Clicking on the forum's title will bring the user to another page showing the list of threads for that forum. For each forum, this also provides a link to its RSS feed, which returns the last "n" threads of that forum (where "n" is specified in web.config).

  • ~/BrowseThreads.aspx: Browses a forum's threads, page by page. The grid that lists the threads shows the thread's title, the number of times it was read, the number of replies, the author of the last post, and when the last post was created. Power users also see special links to delete, close, or move the thread. The results can be sorted by date, reply count, or view count.

  • ~/ShowThread.aspx: Shows all posts of a thread, in a paginated grid. For each post, it shows the title, body, author's signature, submission date and time, author's name, avatar image, and status description. Power users also see links to delete or edit any post, and a link to close the thread to stop replies. Normal members only see links to edit their own posts.

  • ~/AddEditPost.aspx: Creates a new thread, posts a new reply, or edits an existing message, according to the parameters on the querystring.

  • ~/Forum.rss: Returns an RSS feed of the forum's content. According to the querystring parameters, it can return the feed for a specific subforum or include threads from any subforum, and supports various sorting options. This can retrieve a feed for the sitewide threads (if sorting by date) or for the most active threads (if sorting by reply count).

  • ~/Controls/UserProfile.ascx: This control already exists, as it was developed in Chapter 4 while implementing the membership and profiling system. However, you must extend it here to support the Avatar image and Signature profile properties.

Solution

In this section, we'll cover the implementation of key parts of this module, as described in the "Design" section. But you won't find complete source code printed here, as many similar classes were discussed in other chapters. See the code download to get the complete source code.

Implementing the Database

The most interesting stored procedure is tbh_Forums_InsertPost. This inserts a new record into the tbh_Posts table, and if the new post being inserted is approved it must also update the ReplyCount, LastPostBy, and LastPostDate fields of this post's parent post. Because there are multiple statements in this stored procedure, a transaction is used to ensure that they are both either committed successfully or rolled back:

ALTER PROCEDURE dbo.tbh_Forums_InsertPost
(
   @AddedDate        datetime,
   @AddedBy          nvarchar(256),
@AddedByIP        nchar(15),
   @ForumID          int,
   @ParentPostID     int,
   @Title            nvarchar(256),
   @Body             ntext,
   @Approved         bit,
   @Closed           bit,
   @PostID           int OUTPUT
)
AS
SET NOCOUNT ON

BEGIN TRANSACTION InsertPost

INSERT INTO tbh_Posts
   (AddedDate, AddedBy, AddedByIP, ForumID, ParentPostID, Title, Body, Approved,
      Closed, LastPostDate, LastPostBy)
   VALUES (@AddedDate, @AddedBy, @AddedByIP, @ForumID, @ParentPostID, @Title,
      @Body, @Approved, @Closed, @AddedDate, @AddedBy)

SET @PostID = scope_identity()

-- if the post is approved, update the parent post's
-- ReplyCount and LastReplyDate fields
IF @Approved = 1 AND @ParentPostID > 0
   BEGIN
   UPDATE tbh_Posts SET ReplyCount = ReplyCount + 1, LastPostDate = @AddedDate,
      LastPostBy = @AddedBy
      WHERE PostID = @ParentPostID
   END

IF @@ERROR > 0
   BEGIN
   RAISERROR('Insert of post failed', 16, 1)
   ROLLBACK TRANSACTION InsertPost
   RETURN 99
   END

COMMIT TRANSACTION InsertPost

If the post being inserted must be reviewed before being approved, its parent posts won't be modified because you don't want to count posts that aren't visible. When it gets approved later, the tbh_Forums_ApprovePost stored procedure will set this post's Approved field to 1 and then update its parent post's fields mentioned previously. The ReplyCount field must also be incremented by one, but to update the parent post's LastPostBy and LastPostDate fields, the procedure needs the values of the AddedBy and AddedDate fields of the post being approved, so it executes a fast query to retrieve this information and stores it in local variables, and then it performs the parent post's update using those values, as shown here:

ALTER PROCEDURE dbo.tbh_Forums_ApprovePost
(
 @PostID  int
)
AS

BEGIN TRANSACTION ApprovePost

UPDATE tbh_Posts SET Approved = 1 WHERE PostID = @PostID

-- get the approved post's parent post and added date
DECLARE @ParentPostID   int
DECLARE @AddedDate      datetime
DECLARE @AddedBy        nvarchar(256)

SELECT @ParentPostID = ParentPostID, @AddedDate = AddedDate, @AddedBy = AddedBy
   FROM tbh_Posts
   WHERE PostID = @PostID

-- update the LastPostDate, LastPostBy and ReplyCount fields
-- of the approved post's parent post
IF @ParentPostID > 0
   BEGIN
   UPDATE tbh_Posts
      SET ReplyCount = ReplyCount + 1, LastPostDate = @AddedDate,
         LastPostBy = @AddedBy
      WHERE PostID = @ParentPostID
   END

IF @@ERROR > 0
   BEGIN
   RAISERROR('Approval of post failed', 16, 1)
   ROLLBACK TRANSACTION ApprovePost
   RETURN 99
   END

COMMIT TRANSACTION ApprovePost

Implementing the Data Access Layer

Most of the DAL methods are simply wrappers for stored procedures, so they won't be covered here. The GetThreads method is interesting: it returns the list of threads for a specified forum (a page of the results), and it is passed the page index and the page size. It also takes the sort expression used to order the threads. The method uses SQL Server 2005's ROW_NUMBER function to provide a unique auto-incrementing number to all rows in the table, sorted as specified, and then selects those rows with an index number between the lower and the upper bound of the specified page. The SQL code is very similar to the tbh_Articles_GetArticlesByCategory stored procedure developed for the articles module in Chapter 5. The only difference (other than having the SQL code in a C# class instead of inside a stored procedure) is the fact that the sorting expression expected by the ORDER BY clause is dynamically added to the SQL string, as specified by an input parameter. Here's the method, which is implemented in the MB.TheBeerHouse.DAL.SqlClient.SqlForumsProvider class:

public override List<PostDetails> GetThreads(
   int forumID, string sortExpression, int pageIndex, int pageSize)
{
   using (SqlConnection cn = new SqlConnection(this.ConnectionString))
   {
sortExpression = EnsureValidSortExpression(sortExpression);
      int lowerBound = pageIndex * pageSize + 1;
      int upperBound = (pageIndex + 1) * pageSize;
      string sql = string.Format(@"
SELECT * FROM
(
   SELECT tbh_Posts.PostID, tbh_Posts.AddedDate, tbh_Posts.AddedBy,
   tbh_Posts.AddedByIP, tbh_Posts.ForumID, tbh_Posts.ParentPostID, tbh_Posts.Title,
   tbh_Posts.Approved, tbh_Posts.Closed, tbh_Posts.ViewCount, tbh_Posts.ReplyCount,
   tbh_Posts.LastPostDate, tbh_Posts.LastPostBy, tbh_Forums.Title AS ForumTitle,
   ROW_NUMBER() OVER (ORDER BY {0}) AS RowNum
   FROM tbh_Posts INNER JOIN tbh_Forums ON tbh_Posts.ForumID = tbh_Forums.ForumID
   WHERE tbh_Posts.ForumID = {1} AND ParentPostID = 0 AND Approved = 1
) ForumThreads
WHERE ForumThreads.RowNum BETWEEN {2} AND {3} ORDER BY RowNum ASC",
         sortExpression, forumID, lowerBound, upperBound);

      SqlCommand cmd = new SqlCommand(sql, cn);
      cn.Open();
      return GetPostCollectionFromReader(ExecuteReader(cmd), false);
   }
}

At the beginning of the preceding method's body, the sortExpression string is passed to a method named EnsureValidSortExpression (shown below), and its result is assigned to the sortExpression variable. EnsureValidSortExpression, as its name clearly suggests, ensures that the input string is a valid sort expression that references a field in the tbh_Posts table, and not some illegitimate SQL substring used to perform a SQL injection attack. You should always do this kind of validation when building a dynamic SQL query by concatenating multiple strings coming from different sources (this is not necessary when using parameters, but unfortunately the ORDER BY clause doesn't support the use of parameters). Following is the method's implementation:

protected virtual string EnsureValidSortExpression(string sortExpression)
{
   if (string.IsNullOrEmpty(sortExpression))
      return "LastPostDate DESC";

   string sortExpr = sortExpression.ToLower();
   if (!sortExpr.Equals("lastpostdate") && !sortExpr.Equals("lastpostdate asc") &&
      !sortExpr.Equals("lastpostdate desc") && !sortExpr.Equals("viewcount") &&
      !sortExpr.Equals("viewcount asc") && !sortExpr.Equals("viewcount desc") &&
      !sortExpr.Equals("replycount") && !sortExpr.Equals("replycount asc") &&
      !sortExpr.Equals("replycount desc") && !sortExpr.Equals("addeddate") &&
      !sortExpr.Equals("addeddate asc") && !sortExpr.Equals("addeddate desc") &&
      !sortExpr.Equals("addedby") && !sortExpr.Equals("addedby asc") &&
      !sortExpr.Equals("addedby desc") && !sortExpr.Equals("title") &&
      !sortExpr.Equals("title asc") && !sortExpr.Equals("title desc") &&
      !sortExpr.Equals("lastpostby") && !sortExpr.Equals("lastpostby asc") &&
      !sortExpr.Equals("lastpostby desc"))
   {
      return "LastPostDate DESC";
   }
   else
   {
      if (sortExpr.StartsWith("title"))
sortExpr = sortExpr.Replace("title", "tbh_posts.title");
      if (!sortExpr.StartsWith("lastpostdate"))
         sortExpr += ", LastPostDate DESC";
      return sortExpr;
   }
}

As you see, if the sortExpression is null or an empty string, or if it doesn't reference a valid field, the method returns "LastPostDate DESC" as the default, which will sort the threads from the newest to the oldest.

Implementing the Business Logic Layer

The Entity Model and the BLL of this module are similar to those used in other chapters — Chapter 5 in particular. It employs the same patterns for retrieving and managing data by using LINQ to Entities, caching and purging data, and so on. The business layer is composed of two repositories, each inherit from BaseForumRepository. The BaseForumRepository then inherits from BaseRepository, the same architecture used in the Articles module where the BaseForumRepository holds values specific to the forums modules shared between the PostsRepository and the ForumsRepository. Both of the repositories should have methods that parallel the methods contained in the previous editions BLL classes.

The GetUnapprovedPosts method returns a list of post that need to be approved by an administrator. The main difference between this version and the last edition is the ability to sort by the IsThreadPost property. This is an immutable property and not actually part of the entity model and, therefore, cannot be used in the LINQ query used against the database.

Public Function GetUnapprovedPosts() As List(Of Post)

            Return (From lPost In MyBase.Forumctx.Posts.Include("Forum") _
                Where lPost.Approved = False _
                Order By lPost.AddedDate Descending).ToList

End Function

Implementing the User Interface

Before you start coding the user interface pages, you should modify the web.config file to add the necessary profile properties to the <profile> section. The required properties are AvatarUrl and Signature, both of type string, and a Posts property, of type integer, used to store the number of posts submitted by the user. They are used by authenticated users, and are defined within a Forum group, as shown here:

<profile defaultProvider="TBH_ProfileProvider">
   <providers>...</providers>
   <properties>
      <add name="FirstName" type="String" />
      <add name="LastName" type="String" />
      <!-- ...other properties here... -->
      <group name="Forum">
         <add name="Posts" type="Int32" />
         <add name="AvatarUrl" type="String" />
<add name="Signature" type="String" />
      </group>
      <group name="Address">...</group>
      <group name="Contacts">...</group>
      <group name="Preferences">...</group>
   </properties>
</profile>

You must also change the UserProfile.ascx user control accordingly, so that it sets the new AvatarUrl and Signature properties (but not the Posts property, because that can only be set programmatically). This modification just needs a few lines of markup code in the .ascx file, and a couple of lines of C# code to read and set the properties, so I won't show them here. Once this "background work" is done, you can start creating the pages.

Administering and Viewing Forums

The definition of a subforum is almost identical to that of article categories employed in Chapter 5, with the unique addition of the Moderated field. The DAL and BLL code is similar to that developed earlier, but the UI for adding, updating, deleting, and listing forums is also quite similar. Therefore, I won't cover those pages here, but Figure 8-3 shows how they should look. As usual, consult the code download for the complete code.

Figure 8-3

Figure 8.3. Figure 8-3

The AddEditPost.aspx Page

The AddEditPost.aspx page has a simple interface, with a textbox for the new post's title, a FCKeditor instance to create the post's body with a limited set of HTML formatting, and a checkbox to indicate that you won't allow replies to the post (the checkbox is only visible when a user creates a new thread and is not replying to an existing thread, or the user is editing an existing post). Figure 8-4 shows how it is presented to the user who wants to create a new thread.

Figure 8-4

Figure 8.4. Figure 8-4

The page's markup is as simple, having only a few controls, so it's not shown here. The page inherits from the ForumPage class that defines a few properties that are driven by the querystring and use the PrimaryKey property pattern discussed in previous chapters.

Public Property ForumId() As Integer
        Get
            Return PrimaryKeyId("ForumId")
        End Get
Set(ByVal Value As Integer)
            PrimaryKeyId("ForumId") = Value
        End Set
    End Property

    Public Property PostId() As Integer
        Get
            Return PrimaryKeyId("PostId")
        End Get
        Set(ByVal Value As Integer)
            PrimaryKeyId("PostId") = Value
        End Set
    End Property

    Public Property ThreadId() As Integer
        Get
            Return PrimaryKeyId("ThreadId")
        End Get
        Set(ByVal Value As Integer)
            PrimaryKeyId("ThreadId") = Value
        End Set
    End Property

    Public Property QuotePostID() As Integer
        Get
            Return PrimaryKeyId("QuotePostID")
        End Get
        Set(ByVal Value As Integer)
            PrimaryKeyId("QuotePostID") = Value
        End Set
    End Property

The page's code-behind class defines a few private variables used to store calculated values:

Private isNewThread As Boolean = False
Private isNewReply As Boolean = False
Private isEditingPost As Boolean = False

Not all variables are used in every function of the page. The following list defines whether these variables are used and how they are set for each function of the page:

  • Creating a new thread:

    • forumID — Set with the ForumID querystring parameter.

    • threadID — Not used.

    • postID — Not used.

    • quotePostID — Not used.

    • isNewThread — Set to true.

    • isNewReply — Set to false.

    • isEditingPost — Set to false.

  • Posting a new reply to an existing thread:

    • forumID — Set with the ForumID querystring parameter.

    • threadID — Set with the ThreadID querystring parameter, which is the ID of the target thread for the new reply.

    • postID — Not used.

    • quotePostID — Not used.

    • isNewThread — Set to false.

    • isNewReply — Set to true.

    • isEditingPost — Set to false.

  • Quoting an existing post to be used as a base for a new reply to an existing thread:

    • forumID — Set with the ForumID querystring parameter.

    • threadID — Set with the ThreadID querystring parameter.

    • postID — Not used.

    • quotePostID — Set with the QuotePostID querystring parameter, which is the ID of the post to quote.

    • isNewThread — Set to false.

    • isNewReply — Set to true.

    • isEditingPost — Set to false.

  • Editing an existing post:

    • forumID — Set with the ForumID querystring parameter.

    • threadID — Set with the ThreadID querystring parameter (necessary for linking back to the thread's page after submitting the change, or if the editor wants to cancel the editing and go back to the previous page).

    • postID — Set with the PostID querystring parameter, which is the ID of the post to edit.

    • quotePostID — Not used.

    • isNewThread — Set to false.

    • isNewReply — Set to false.

    • isEditingPost — Set to true.

The variables are set in the Page's Load event handler, which also calls the code to load the body of the post to edit or quote, sets the link to go back to the previous page, and checks whether the current user is allowed to perform the requested function. Here's the code:

Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
Handles Me.Load

      isNewThread = ((PostId = 0) And (ThreadId = 0))
isEditingPost = Not (PostId = 0)
      isNewReply = (Not isNewThread And Not isEditingPost)

      ' show/hide controls and load data according to the parameters above
      If Not Me.IsPostBack Then

         lnkThreadList.NavigateUrl = String.Format(lnkThreadList.NavigateUrl,
ForumId)
         lnkThreadPage.NavigateUrl = String.Format(lnkThreadPage.NavigateUrl,
ThreadId)
         txtBody.BasePath = Me.BaseUrl & "FCKeditor/"
         chkClosed.Visible = isNewThread

         BindPost()

      End If
End Sub

The BindPost method actually retrieves the post and binds the contents to the appropriate controls. The method takes care to bind the controls as needed for either editing or posting a new thread. It also has a security check to force a login is the user is not authorized to make edit the post. For example, if someone happened to figure out a hack to retrieve the post for editing and was not a site administrator or the original poster, it would ask them to authenticate.

Private Sub BindPost()
      Using lPost As New PostsRepository

         If isEditingPost Then
            ' load the post to edit, and check that the current user has the
            ' permission to do so
            Dim post As Post = lPost.GetPostById(PostId)
            If Not isModerator AndAlso _
               Not (Me.User.Identity.IsAuthenticated And _
               Me.User.Identity.Name.Equals(post.AddedBy.ToLower)) Then
               Me.RequestLogin()
            End If

            lblEditPost.Visible = True
            btnSubmit.Text = "Update"
            txtTitle.Text = post.Title
            txtBody.Value = post.Body
            panTitle.Visible = isModerator
         ElseIf isNewReply Then
            ' chech whether the thread the user is adding a reply to is still open
            Dim post As Post = lPost.GetPostById(ThreadId)
            If post.Closed Then
               Throw New ApplicationException( _
                  "The thread you tried to reply to has been closed.")
            End If

            lblNewReply.Visible = True
            txtTitle.Text = "Re: " & post.Title
            lblNewReply.Text = String.Format(lblNewReply.Text, post.Title)
            ' if the ID of a post to be quoted is passed on the querystring, load
' that post and prefill the new reply's body with that post's body
            If quotePostID > 0 Then
               Dim quotePost As Post = lPost.GetPostById(quotePostID)
               txtBody.Value = String.Format( _
                  "<blockquote><hr noshade="""" size=""1"" />" & _
                  "<b>Originally posted by {0}</b><br /><br />{1}" & _
                  "<hr noshade="""" size=""1"" /></blockquote>", _
                  quotePost.AddedBy, quotePost.Body)
            End If
         ElseIf isNewThread Then
            lblNewThread.Visible = True
            lnkThreadList.Visible = True
            lnkThreadPage.Visible = False
         End If

      End Using
End Sub

When the user clicks the Submit button, the previously discussed class fields are used again to determine whether an AddPost or an UpdatePost is required. When editing a post, a line is dynamically added at the end of the post's body to log the date and time of the update, and the editor's name. When the post is inserted, you must also check whether the target forum is moderated, and if it is, you can only pass true to the InsertPost's approved parameter if the current user is a power user (administrator, editor, or moderator). After inserting the post, you also increment the author's Posts profile property. Here's the full code for the Submit button's OnClick event handler:

Protected Sub btnSubmit_Click(ByVal sender As Object, ByVal e As System.EventArgs)
 Handles btnSubmit.Click

      Using lPostrpt As New PostsRepository

         If isEditingPost Then
            ' when editing a post, a line containing the current Date/Time and the
            ' name of the user making the edit is added to the post's body so that
            ' the operation gets logged
            Dim body As String = Helpers.FilterProfanity(txtBody.Value)
            body &= String.Format("<p>-- {0}: post edited by {1}.</p>", _
               DateTime.Now.ToString, Me.User.Identity.Name)
            ' edit an existing post
            Dim lPostItem As Post = lPostrpt.GetPostById(PostId)
            lPostItem.Title = txtTitle.Text
            lPostItem.Body = body

            lPostItem.UpdatedDate = Now
            lPostItem.UpdatedBy = UserName

            lPostrpt.UpdatePost(lPostItem)
            panInput.Visible = False
            panFeedback.Visible = True
         Else

            Dim lPostItem As New Post

            Using lForumrpt As New ForumsRepository
Dim forum As Forum = lForumrpt.GetForumById(ForumId)

               lPostItem.ForumId = ForumId
               lPostItem.ParentPostID = ThreadId
               lPostItem.Title = txtTitle.Text
               lPostItem.Body = txtBody.Value
               lPostItem.Closed = chkClosed.Checked
               lPostItem.LastPostDate = Now
               lPostItem.LastPostBy = UserName
               lPostItem.UpdatedDate = Now
               lPostItem.UpdatedBy = UserName

               If (forum.Moderated) Then
                  If Not isModerator Then
                     lPostItem.Approved = False
                  End If
               End If

               lPostItem.Active = True
               lPostItem.AddedDate = Now
               lPostItem.AddedBy = UserName
               lPostItem.AddedByIP = Request.UserHostAddress

               ' insert the new post
               If lPostrpt.AddPost(lPostItem) Then

                  panInput.Visible = False
                  ' increment the user's post counter
                  Dim lPosts As Integer =
 profile.GetProfileGroup("Forum").GetPropertyValue("Posts")
                  profile.GetProfileGroup("Forum").SetPropertyValue("Posts",
lPosts + 1)

                  ' show the confirmation message saying that approval is
                  ' required, according to the target forum's moderated property

                  If forum.Moderated Then
                     If Not isModerator Then
                        panApprovalRequired.Visible = True
                     Else
                        panFeedback.Visible = True
                     End If
                  Else
                     panFeedback.Visible = True
                  End If

                  'Just in case they corrected an error.
                  ltlStatus.Visible = False
               Else

                  ltlStatus.Visible = True

                  For Each kv As KeyValuePair(Of String, Exception) In
 lForumrpt.ActiveExceptions
ltlStatus.Text += "<BR/>" & DirectCast(kv.Value, Exception)
.Message & "<BR/>"
                  Next

               End If

            End Using

         End If

      End Using

End Sub

The ManageUnapprovedPosts.aspx Page

The ManageUnapprovedPosts.aspx page enables power users to see the list of messages waiting for approval for moderated forums, and allows them to review their content and then either approve or delete them. The page is pretty simple, as there's just a ListView that shows the title and a few other fields of the posts, without support for pagination or sorting. Next to the poster's name is an icon to expand the body of the post for review. The entire ListView is wrapped in an UpdatePanel to make interacting with the items more seamless. A screenshot is shown in Figure 8-5.

Figure 8-5

Figure 8.5. Figure 8-5

The code to manage this list has a small peculiarity: when the editor clicks on the post's title or the GoDown.gif icon the full body of the post is exposed. This way the administrator can review the body of the post before approving or deleting it. This is done through the ListView's ItemDataBound event handler by adding OnClick event handlers to a SPAN wrapping the title and the image. Both of these elements are designated as server-side controls.

The method first grabs an instance of each of the controls, the SPAN is cast as an HTMLGenericControl and the Image as an HTMLImage control. Then the same attribute is applied to use the toggleDivState function defined in the TBH.js file. The body of the post is contained in a DIV element that is dynamically named 'body' + PostId. I wanted to do this to have control over the ID on the client because ASP.NET would have made the ID hard to work with otherwise. I could have made the DIV a server-side element, too, and used the UniqueID property but chose not to, to make it simpler. Also notice the Approve and Delete ImageButtons also have a special confirmation message applied to their click events.

Private Sub lvPosts_ItemDataBound(ByVal sender As Object, ByVal e As
System.Web.UI.WebControls.ListViewItemEventArgs)
Handles lvPosts.ItemDataBound

      Dim lvdi As ListViewDataItem = DirectCast(e.Item, ListViewDataItem)

      If lvdi.ItemType = ListViewItemType.DataItem Then

         Dim iGoDown As System.Web.UI.HtmlControls.HtmlImage =
DirectCast(e.Item.FindControl("iGoDown"),
System.Web.UI.HtmlControls.HtmlImage)
         Dim btnApprove As ImageButton = DirectCast(
e.Item.FindControl("btnApprove"), ImageButton)
         btnApprove.OnClientClick = "if (
confirm('Are you sure you want to approve this post?') == false)
return false;"
         btnApprove.ToolTip = "Approve this post"
         Dim btnDelete As ImageButton =
DirectCast(e.Item.FindControl("btnDelete"), ImageButton)
         btnDelete.OnClientClick = "if (
confirm('Are you sure you want to delete this post?') == false)
 return false;"
         btnDelete.ToolTip = "Delete this post"
         Dim dTitle As HtmlGenericControl = DirectCast(e.Item.FindControl("dTitle")
, HtmlGenericControl)

         Dim lPost As Post = DirectCast(lvdi.DataItem, Post)

         If Not IsNothing(iGoDown) Then
            iGoDown.Attributes.Add("OnClick",
String.Format("toggleDivState('{0}'),", "body" & lPost.PostID))
         End If

         If Not IsNothing(dTitle) Then
            dTitle.Attributes.Add("OnClick",
String.Format("toggleDivState('{0}'),", "body" & lPost.PostID))
         End If

      End If

End Sub

When the administrator deletes a post, the ListView's ItemDeleting event is fired and the typical delete routine is used. When the administrator approves a post the ItemCommand event handler is called and the ApprovePost method of the PostRepository is called.

Private Sub lvPosts_ItemCommand(ByVal sender As Object, ByVal e As
System.Web.UI.WebControls.ListViewCommandEventArgs)
Handles lvPosts.ItemCommand

      Select Case e.CommandName
         Case "Approve"

            Using lPostrpt As New PostsRepository

               lPostrpt.ApprovePost(Convert.ToInt32(e.CommandArgument))

            End Using

            BindUnapprovedPosts()

      End Select

End Sub

The BrowseThreads.aspx Page

The BrowseThreads.aspx page takes a ForumID parameter on the querystring with the ID of the forum the user wants to browse and fills a paginable GridView control with the thread list returned by the Post.GetThreads business method. Figure 8-6 shows a screenshot of this page.

Figure 8-6

Figure 8.6. Figure 8-6

In addition to the GridView control with the threads, the page also features a DropDownList at the top of the page, which lists all available forums (retrieved by an ObjectDataSource that uses the Forum.GetForums business method) and allows users to quickly navigate to a forum by selecting one. The DropDownList onchange client-side (JavaScript) event redirects the user to the same BrowseThreads.aspx page but with the newly selected forum's ID on the querystring:

<asp:DropDownList ID="ddlForums" runat="server" DataSourceID="objForums"
   DataTextField="Title" DataValueField="ID"
   onchange="javascript:document.location.href='BrowseThreads.aspx?ForumID='
+ this.value;" />
<asp:ObjectDataSource ID="objForums" runat="server" SelectMethod="GetForums"
   TypeName="MB.TheBeerHouse.BLL.Forums.Forum" />

The GridView is bound to another ObjectDataSource control, which specifies the methods to select and delete data. Because you want to support pagination, you use the GetThreadCount method to return the total thread count. To support sorting you must set SortParameterName to the name of the parameter that the SelectMethod (i.e., GetThreads) will use to receive the sort expression; in this case, as shown earlier, it's sortExpression. Here's the complete declaration of the ObjectDataSource:

<asp:ObjectDataSource ID="objThreads" runat="server"
   TypeName="MB.TheBeerHouse.BLL.Forums.Post"
   DeleteMethod="DeletePost" SelectMethod="GetThreads"
   SelectCountMethod="GetThreadCount"
   EnablePaging="true" SortParameterName="sortExpression">
   <DeleteParameters>
      <asp:Parameter Name="id" Type="Int32" />
   </DeleteParameters>
   <SelectParameters>
      <asp:QueryStringParameter Name="forumID"
         QueryStringField="ForumID" Type="Int32" />
   </SelectParameters>
</asp:ObjectDataSource>

The following GridView control has both the AllowPaging and AllowSorting properties set to true, and it defines the following columns:

  • A TemplateColumn that displays an image representing a folder, which is used to identify a discussion thread. A templated column is used in place of a simpler ImageColumn, because the image being shown varies according to the number of posts in the thread. If the post count reaches a certain value (specified in the configuration), it will be considered a hot thread, and a red icon will be used to highlight it.

  • A TemplateColumn defining a link to the ShowThread.aspx page on the first line, with the thread's title as the link's text, and the thread's author's name in smaller text on the second line. The link on the first line also includes the thread's ID on the querystring so that the page will load that specific thread's posts.

  • A TemplateColumn that shows the date of the thread's last post on the first line, and the name of the author who entered the thread's last post on the second line. The column's SortExpression is LastPostDate.

  • A BoundField column that shows the thread's ReplyCount and has a header link that sorts threads on this column.

  • A BoundField column that shows the thread's ViewCount and has a header link that sorts threads on this column.

  • A HyperLinkField column pointing to the MoveThread.aspx page, which takes the ID of the thread to move on the querystring. This column will not be shown if the current user is not a power user.

  • A ButtonField column to close the thread and stop replies to it. This column will not be shown if the current user is not a power user.

  • A ButtonField column to delete the thread with all its posts. This column will not be shown if the current user is not a power user.

  • Following is the complete markup code for the GridView:

    <asp:GridView ID="gvwThreads" runat="server" AllowPaging="True"
       AutoGenerateColumns="False" DataSourceID="objThreads" PageSize="25"
       AllowSorting="True" DataKeyNames="ID" OnRowCommand="gvwThreads_RowCommand"
       OnRowCreated="gvwThreads_RowCreated">
       <Columns>
          <asp:TemplateField ItemStyle-Width="16px">
             <ItemTemplate>
                <asp:Image runat="server" ID="imgThread" ImageUrl="~/Images/Thread.gif"
                   Visible='<%# (int)Eval("ReplyCount") <
                      Globals.Settings.Forums.HotThreadPosts %>'
                   GenerateEmptyAlternateText="" />
                <asp:Image runat="server" ID="imgHotThread"
                   ImageUrl="~/Images/ThreadHot.gif"
                   Visible='<%# (int)Eval("ReplyCount") >=
                      Globals.Settings.Forums.HotThreadPosts %>'
                   GenerateEmptyAlternateText="" />
             </ItemTemplate>
             <HeaderStyle HorizontalAlign="Left" />
          </asp:TemplateField>
          <asp:TemplateField HeaderText="Title">
             <ItemTemplate>
                <asp:HyperLink ID="lnkTitle" runat="server" Text='<%# Eval("Title") %>'
                   NavigateUrl='<%# "ShowThread.aspx?ID=" + Eval("ID") %>' /><br />
                <small>by <asp:Label ID="lblAddedBy" runat="server"
                   Text='<%# Eval("AddedBy") %>'></asp:Label></small>
             </ItemTemplate>
             <HeaderStyle HorizontalAlign="Left" />
          </asp:TemplateField>
          <asp:TemplateField HeaderText="Last Post" SortExpression="LastPostDate">
             <ItemTemplate>
                <small><asp:Label ID="lblLastPostDate" runat="server"
                   Text='<%# Eval("LastPostDate", "{0:g}") %>'></asp:Label><br />
                by <asp:Label ID="lblLastPostBy" runat="server"
                   Text='<%# Eval("LastPostBy") %>'></asp:Label></small>
             </ItemTemplate>
             <ItemStyle HorizontalAlign="Center" Width="130px" />
             <HeaderStyle HorizontalAlign="Center" />
          </asp:TemplateField>
          <asp:BoundField HeaderText="Replies" DataField="ReplyCount"
             SortExpression="ReplyCount">
             <ItemStyle HorizontalAlign="Center" Width="50px" />
             <HeaderStyle HorizontalAlign="Center" />
          </asp:BoundField>
          <asp:BoundField HeaderText="Views" DataField="ViewCount"
             SortExpression="ViewCount">
             <ItemStyle HorizontalAlign="Center" Width="50px" />
    <HeaderStyle HorizontalAlign="Center" />
          </asp:BoundField>
          <asp:HyperLinkField
             Text="<img border='0' src='Images/MoveThread.gif' alt='Move thread' />"
             DataNavigateUrlFormatString="~/Admin/MoveThread.aspx?ThreadID={0}"
             DataNavigateUrlFields="ID">
             <ItemStyle HorizontalAlign="Center" Width="20px" />
          </asp:HyperLinkField>
          <asp:ButtonField ButtonType="Image" ImageUrl="~/Images/LockSmall.gif"
             CommandName="Close">
             <ItemStyle HorizontalAlign="Center" Width="20px" />
          </asp:ButtonField>
          <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif"
             DeleteText="Delete thread" ShowDeleteButton="True">
             <ItemStyle HorizontalAlign="Center" Width="20px" />
          </asp:CommandField>
       </Columns>
       <EmptyDataTemplate><b>No threads to show</b></EmptyDataTemplate>
    </asp:GridView>

There are just a few lines of code in the page's code-behind class. In the Page_Init event handler, you set the grid's PageSize to the value read from the configuration settings, overwriting the default hard-coded value used previously:

protected void Page_Init(object sender, EventArgs e)
{
   gvwThreads.PageSize = Globals.Settings.Forums.ThreadsPageSize;
}

In the Page_Load event handler, there's some simple code that uses the ID passed on the querystring to load a Forum object representing the forum: the forum's title read from the object is used to set the page's Title. Then the code preselects the current forum from the DropDownList at the top, sets the ForumID parameter on the hyperlinks that create a new thread, and hides the last three GridView columns if the current user is not a power user:

protected void Page_Load(object sender, EventArgs e)
{
   if (!this.IsPostBack)
   {
      string forumID = this.Request.QueryString["ForumID"];
      lnkNewThread1.NavigateUrl = string.Format(lnkNewThread1.NavigateUrl,
         forumID);
      lnkNewThread2.NavigateUrl = lnkNewThread1.NavigateUrl;

      Forum forum = Forum.GetForumByID(int.Parse(forumID));
      this.Title = string.Format(this.Title, forum.Title);
      ddlForums.SelectedValue = forumID;

      // if the user is not an admin, editor or moderator, hide the grid's column
// with the commands to delete, close or move a thread
      bool canEdit = (this.User.Identity.IsAuthenticated &&
         (this.User.IsInRole("Administrators") || this.User.IsInRole("Editors") ||
          this.User.IsInRole("Moderators")));
      gvwThreads.Columns[5].Visible = canEdit;
      gvwThreads.Columns[6].Visible = canEdit;
      gvwThreads.Columns[7].Visible = canEdit;
   }
}

The click on the threads' Delete button is handled automatically by the GridView and its companion ObjectdataSource control. To make the Close button work, you have to manually handle the RowCommand event handler and call the Post.CloseThread method, as shown here:

protected void gvwThreads_RowCommand(object sender, GridViewCommandEventArgs e)
{
   if (e.CommandName == "Close")
   {
      int threadPostID = Convert.ToInt32(
         gvwThreads.DataKeys[Convert.ToInt32(e.CommandArgument)][0]);
      MB.TheBeerHouse.BLL.Forums.Post.CloseThread(threadPostID);
   }
}

The MoveThread.aspx Page

The MoveThread.aspx page contains a DropDownList with the list of available forums and allows power users to move the thread (whose ThreadID is passed on the querystring) to one of the forums, after selecting it and clicking the OK button. Figure 8-7 shows this simple user interface.

Figure 8-7

Figure 8.7. Figure 8-7

The DropDownList is filled with an instructional message by calling the BindForums method in the ForumPage class. The BindForums method uses a ForumsRepository to bind the ActiveForums to the supplied ListControl. Using a ListControl ultimately provides the flexibility to bind to a DropDownList, RadioButtonList, or any other web control derived from ListControl. Ultimately, they all work the same when it comes to binding. Finally, a customized instruction string is inserted at the top of the ListContol's items collection. If the ForumId is greater than 0, then it is selected in the list.

Protected Sub BindForums(ByVal vListControl As ListControl,
ByVal vInstruction As String)

        Using lforumRpt As New ForumsRepository

            vListControl.DataSource = lforumRpt.GetActiveForums
            vListControl.DataBind()

            vListControl.Items.Insert(0, New ListItem(vInstruction, "0"))

            If ForumId > 0 Then
                vListControl.SelectedValue = ForumId
            End If

        End Using

End Sub

The MoveThread page calls the BindPostInfo method after it binds the forum's DropDownList. This method binds information about the post that is being moved to the associated controls.

Private Sub BindPostInfo()

      Using lPostrpt As New PostsRepository
         Dim post As Post = lPostrpt.GetPostById(ThreadId)
         lblThreadTitle.Text = post.Title
         lblForumTitle.Text = post.ForumTitle
         ddlForums.SelectedValue = post.ForumId.ToString()
      End Using

End Sub

When the user clicks the Submit button the PostsRepository is used to move the thread and then redirect to the BrowseThreads.aspx page at the root of the site.

Private Sub btnSubmit_Click(ByVal sender As Object, ByVal e As System.EventArgs)
Handles btnSubmit.Click

      Using lPostrpt As New PostsRepository
         Dim lforumID As Integer = Integer.Parse(ddlForums.SelectedValue)
         lPostrpt.MoveThread(ThreadId, ForumId)
         Me.Response.Redirect("~/BrowseThreads.aspx?ForumID=" & forumID.ToString())
      End Using

End Sub

The ShowThread.aspx Page

The ShowThread.aspx page renders a paginable list showing all posts of the thread, whose ThreadID is passed on the querystring. Figure 8-8 shows this ListView in action.

Figure 8-8

Figure 8.8. Figure 8-8

Some of the information (such as the post's title, body, author, and date/time) is retrieved from the bound data retrieved by calling the GetThread method of the PostRepository. Some other data, such as the author's avatar, number of posts, and signature are retrieved from the profile associated with the membership account named after the post author's name. The controls that show this profile data are bound to an expression that calls the GetUserProfile method, which takes the author's name and returns an instance of ProfileCommon for that user. Using the dynamically generated, strongly typed ProfileCommon object, you can easily reference the profile groups and subproperties. The following code declares the ListView's ItemTemplate, which defines the links to edit and delete the post (these will be hidden by code in the code-behind if the current user should not see them), and then defines controls bound to the user's Posts and AvatarUrl profile properties in the first table cell:

<ItemTemplate>
         <tr>
            <td valign="top">
               <div class="posttitle">
                  <asp:HyperLink runat="server" ID="lnkEditPost"
ImageUrl="~/Images/Edit.gif"
NavigateUrl="~/AddEditPost.aspx?ForumID={0}
&ThreadID={1}&PostID={2}" />&nbsp;
                  <asp:ImageButton runat="server" ID="btnDeletePost"
ImageUrl="~/Images/Delete.gif"
                     OnClientClick="if (
confirm('Are you sure you want to delete this {0}?') == false)
return false;" />&nbsp;&nbsp;
</div>
               <asp:Literal ID="lblAddedDate" runat="server"
Text='<%# Eval("AddedDate", "{0:D}<br/><br/>{0:T}") %>' />
               <hr />
               <asp:Literal ID="lblAddedBy" runat="server"
Text='<%# Eval("AddedBy") %>' /><br />
               <br />
               <small>
                  <asp:Literal ID="lblPosts" runat="server"
Text='<%# "Posts: " &
GetNoofPostForUser(Eval("AddedBy")).ToString() %>' />
                  <asp:Literal ID="lblPosterDescription" runat="server"
Text='<%# "<br />" &
GetPosterDescription(GetNoofPostForUser(Eval("AddedBy"))) %>'
                     Visible='<%# GetNoofPostForUser(Eval("AddedBy")) >=
Settings.Forums.BronzePosterPosts %>' /></small><br />
               <br />
               <asp:Panel runat="server" ID="panAvatar" Visible='<%#
GetPosterAvatar(Eval("AddedBy")).Length > 0 %>'>
                  <asp:Image runat="server" ID="imgAvatar"
ImageUrl='<%# GetPosterAvatar(Eval("AddedBy")) %>' />
                  <br />
                  <br />
               </asp:Panel>
            </td>
            <td valign="top">
               <div class="posttitle">
                  <asp:Literal ID="lblTitle" runat="server"
Text='<%# Eval("Title") %>' /></div>
               <div class="postbody">
                  <asp:Literal ID="lblBody" runat="server"
Text='<%# Eval("Body") %>' /><br />
                  <br />
                  <asp:Literal ID="lblSignature" runat="server"
Text='<%# ConvertToHtml(GetPosterSignature(Eval("AddedBy")))
%>' /><br />
                  <br />
                  <div style="text-align: right;">
                     <asp:HyperLink runat="server" ID="lnkQuotePost"
NavigateUrl="~/AddEditPost.aspx?ForumID={0}&ThreadID={1}&QuotePostID={2}">
Quote Post</asp:HyperLink>
                  </div>
               </div>
            </td>
         </tr>
</ItemTemplate>

The second cell renders the post's title, the body, and then the user's Signature profile property. Because the signature is in plain text, though, it first passes through a helper method named ConvertToHtml, which transforms the signature into simple HTML (it replaces carriage returns with <br/> tags, replaces multiple spaces and tabs with "&nbsp;", etc.). At the bottom, it has a HyperLink to the AddEditPost.aspx page, which creates a new reply by quoting the current post's body.

There's some interesting code in the code-behind class: in the preceding code, you can see that the GetUserProfile method is called six times for every single post. This can cause performance problems when you consider how many times this might execute in one page cycle. The same thread will likely have multiple posts by the same user: in a typical thread of 20 posts, four of them might be from the same user. This means we make 24 calls to GetUserProfile for the same user. This method uses ASP.NET's Profile.GetProfile method to retrieve a ProfileCommon object for the specified user, which unfortunately doesn't cache the result. This means that every time you call Profile.GetProfile, it will run a query to SQL Server to retrieve the user's profile, and then build the ProfileCommon object to be returned. In our situation, this would be an incredible waste of resources, because after the first query for a specific user, the next 23 queries for that user would produce the same result. To prevent this kind of waste, we'll use the GetUserProfile method to wrap the call to Profile.GetProfile by adding simple caching support that will last as long as the page's lifetime. It uses a Hashtable, which uses the username as a key, and the ProfileCommon object as the value; if the requested profile is not found in the Hashtable when the method is called, it forwards the call to Profile.GetProfile and then saves the result in the Hashtable for future needs. Here's how it's implemented:

Dim profiles As New Hashtable()

Protected Function GetPostUserProfile(ByVal userName As Object) As ProfileBase
      Dim name As String = CStr(userName)
      If Not profiles.Contains(name) Then
         Dim profile As ProfileBase = Helpers.GetUserProfile(name)
         profiles.Add(name, profile)
         Return profile
      Else
         Return CType(profiles(userName), ProfileBase)
      End If
End Function

There's another helper method on the page, GetPosterDescription, which returns the user's status description according to the user's number of posts. It compares the number with the values of the GoldPosterPosts, SilverPosterPosts, and BronzePosterPosts configuration settings and returns the appropriate description:

Protected Function GetPosterDescription(ByVal posts As Integer) As String
      If posts >= Settings.Forums.GoldPosterPosts Then
         Return Settings.Forums.GoldPosterDescription
      ElseIf posts >= Settings.Forums.SilverPosterPosts Then
         Return Settings.Forums.SilverPosterDescription
      ElseIf posts >= Settings.Forums.BronzePosterPosts Then
         Return Settings.Forums.BronzePosterDescription
      Else
         Return String.Empty
      End If
End Function

The rest of the page's code-behind is pretty typical. For example, you handle the list's ItemDataBound event to show, or hide, the post's edit link according to whether the current user is the post's author or a power user, or just another user. It also sets the delete button's CommandName to either DeleteThread or DeletePost or hides the link to quote the post according to whether the thread is closed. The following code shows this:

Private Sub lvPosts_ItemDataBound(ByVal sender As Object,
ByVal e As System.Web.UI.WebControls.ListViewItemEventArgs)
Handles lvPosts.ItemDataBound

      If e.Item.ItemType = ListViewItemType.DataItem Then

         Dim lvdi As ListViewDataItem = DirectCast(e.Item, ListViewDataItem)
         Dim lPost As Post = CType(lvdi.DataItem, Post)
         Dim threadID As Integer = lPost.ParentPostID
         If lPost.IsFirstPost Then threadID = lPost.PostID

         If Not IsNothing(lPost) Then

            ' the link for editing the post is visible to the post's author, and to
            ' administrators, editors and moderators
            Dim lnkEditPost As HyperLink =
CType(e.Item.FindControl("lnkEditPost"), HyperLink)


            lnkEditPost.NavigateUrl = String.Format(lnkEditPost.NavigateUrl,
lPost.ForumId, threadID, lPost.PostID)
            lnkEditPost.Visible = IsModeratorOrPoster(lPost.AddedBy.ToLower())

            ' the link for deleting the thread/post is visible only to
administrators, editors and moderators
            Dim btnDeletePost As ImageButton =
CType(e.Item.FindControl("btnDeletePost"), ImageButton)
            If lPost.IsFirstPost Then
               btnDeletePost.OnClientClick =
String.Format(btnDeletePost.OnClientClick, "entire thread")
               btnDeletePost.CommandName = "DeleteThread"
            Else
               btnDeletePost.OnClientClick =
String.Format(btnDeletePost.OnClientClick, "post")
               btnDeletePost.CommandName = "DeletePost"
            End If
            btnDeletePost.CommandArgument = lPost.PostID.ToString()
            btnDeletePost.Visible = isModerator

            ' if the thread is not closed, show the link to quote the post
            Dim lnkQuotePost As HyperLink =
CType(e.Item.FindControl("lnkQuotePost"), HyperLink)
            lnkQuotePost.NavigateUrl = String.Format(lnkQuotePost.NavigateUrl, _
               lPost.ForumId, threadID, lPost.PostID)
            If lPost.IsFirstPost Then
               lnkQuotePost.Visible = Not lPost.Closed
            Else
               lnkQuotePost.Visible = Not lPost.ParentPost.Closed
End If

         End If

      End If

End Sub

You also handle the grid's ItemCommand event to process the click action of the post's Delete button. You always call Post.DeletePost to delete a single post, or an entire thread (the situation is indicated by the CommandName property of the method's e parameter), but in the first case you just rebind the ListView to its data source, whereas in the second case you redirect to the page that browses the thread's parent forum's threads after deleting it:

Private Sub lvPosts_ItemCommand(ByVal sender As Object,
ByVal e As System.Web.UI.WebControls.ListViewCommandEventArgs)
Handles lvPosts.ItemCommand

      Select e.CommandName
         Case "DeleteThread"
            Using lPostrpt As New PostsRepository
               Dim threadPostID As Integer = Convert.ToInt32(e.CommandArgument)
               Dim forumID As Integer = lPostrpt.GetPostById(threadPostID).PostID
               lPostrpt.DeletePost(threadPostID)
               Me.Response.Redirect("BrowseThreads.aspx?ForumID=" &
forumID.ToString())
            End Using

         Case "DeletePost"
            Using lPostrpt As New PostsRepository
               Dim postID As Integer = Convert.ToInt32(e.CommandArgument)
               lPostrpt.DeletePost(postID)

               Dim pagerBottom As DataPager =
DirectCast(lvPosts.FindControl("pagerBottom"), DataPager)
               pagerBottom.SetPageProperties(0,
Settings.Forums.PostsPageSize, False)
               lvPosts.DataBind()
            End Using

      End Select

End Sub

Producing and Consuming RSS Feeds

The forums module includes the RSSForum handler, which returns an RSS feed of the forums' threads, either for a single subforum or for all subforums, depending on whether a ForumID parameter is passed on the querystring or not. It also supports a SortExpr parameter that specifies one of the supported sort expressions, such as "LastPostDate DESC" (the default), "ReplyCount DESC", and so forth.

The main difference between the handler and the previous edition is the use of a custom httpHandler instead of a Web Form to produce the RSS. As discussed in the Article's module chapter, this is a much more efficient method to produce an RSS feed. The handler uses the same XML Literal mechanisms for VB.NET used in Chapter 5 and LINQ to XML, XDocument, methodology used for C#. The only real difference is the specific query used to retrieve the posts.

Public Function GetRSSForum(ByVal vForumId As Integer) As IEnumerable(Of Post)

         Dim key As String = CacheKey & "_IEnumerable"

         Forumctx.Posts.MergeOption = Objects.MergeOption.NoTracking
         Dim lPosts As IEnumerable(Of Post)

         If vForumId > 0 Then

            lPosts = (From lPost In Forumctx.Posts _
                    Order By lPost.LastPostDate Descending).Take(10).AsEnumerable

         Else

            lPosts = (From lPost In Forumctx.Posts _
               Where lPost.Forum.ForumID = vForumId _
                    Order By lPost.LastPostDate Descending).Take(10).AsEnumerable

         End If

         Return lPosts

End Function

Remember to make the custom handler actually execute, it must be registered in the web.config file. Since we set the *.rss to invoke the articles handler, this declaration must be before that declaration.

<add verb="*" path="forum.rss" validate="false" type="TheBeerHouse.RSSForum,
TBHBLL, Version=3.5.0.1, Culture=neutral, PublicKeyToken=null" />
<add verb="*" path="*.rss" validate="false" type="TheBeerHouse.RSSFeed,
TBHBLL, Version=3.5.0.1, Culture=neutral, PublicKeyToken=null" />

Securing the Forum Module

While developing the pages, we've already inserted many checks to ensure that only certain users can perform actions such as closing, moving, deleting, and editing posts. Programmatic security is required in some circumstances, but in other cases it suffices to use declarative security to allow or deny access to a resource by a given user or role. For example, the AddEditPost.aspx page must never be accessed by anonymous users in this implementation, and you can easily enforce this restriction by adding a declaration to the web.config file found in the site's root folder: you just need to add a new <location> section with a few <allow> and <deny> elements. There's one other aspect of the AddEditPost.aspx page that should be considered: if a member doesn't respect the site's policies and repeatedly submits messages with spam or offensive language, then you'd like to be able to ban the member from adding any new posts. One way to do this is to block messages coming from that IP address, but it's even better to block that user account from accessing the page. However, you don't want to block that account completely; otherwise, that member would lose access to any other section of the site, which would be too restrictive for that particular crime! The easiest way to handle this is to add a new role called "Posters" to all new users at registration time and then add a declarative restriction to web.config that ensures that only users who belong to the Administrators, Editors, Moderators, or Posters role can access the AddEditPost.aspx page, as shown here:

<location path="AddEditPost.aspx">
   <system.web>
      <authorization>
         <allow roles="Administrators,Editors,Moderators,Posters" />
         <deny users="*"/>
      </authorization>
   </system.web>
</location>

To automatically add a user to the Posters role immediately after the user has registered, you must modify the Register.aspx page developed in Chapter 4 to handle the CreateUserWizard's CreatedUser event (which is raised just after the user has been created), and then call the AddUserToRole method of ASP.NET's Roles class, like this:

Protected Sub CreateUserWizard1_CreatedUser(ByVal sender As Object,
ByVal e As System.EventArgs) Handles CreateUserWizard1.CreatedUser
         Roles.AddUserToRole(CreateUserWizard1.UserName, "Posters")
End Sub

In the future, if you want to remove a given user's right to post new messages, you only need to remove the user from the Posters role, using the EditUser.aspx administration page developed in Chapter 4. This module's administration page also has some <location> section restrictions in the web.config file located under the Admin folder to ensure that only Administrators, Editors, and Moderators can access them.

Summary

In this chapter, you've built a forums system from scratch, and you did it by leveraging much of the work done in earlier chapters, and many of the new features in ASP.NET 2.0. This was a further example showing how to integrate the built-in membership and profile systems into a custom module, as well as reusing other pages and controls (such as the RssReader control) developed previously. Our forums module supports multiple subforums, with optional moderation; it lists threads and replies through custom pagination (with different sorting options), offers support for publishing and consuming standard RSS feeds, and extends the user profiles with forum-specific properties. We also created administration features for deleting, editing, approving, moving, and closing threads and posts. This is a fairly complete forums module that should work well with many small to midsized sites. However, the subject of user forums in general is a big area, and there are many possible options and features that you might want to consider adding to your forums module. Here are a few suggestions to get you started:

  • Add support for some open forums, as a subforum-level option, which would be accessible by anonymous posters.

  • Allow some subforums to have different moderators for more granular security control (especially useful for larger sites that may have multiple moderators who specialize in certain subforums).

  • Add e-mail notification of new forum activity, or you can even send out e-mail message digests. E-mails could also be used by moderators to be notified about new messages waiting to be approved, and you might even allow the moderator to approve a message simply by clicking a link contained in the e-mail, after reviewing the post's body, also included in the e-mail.

  • Support a list of banned words and use regular expressions to replace them with acceptable alternatives, or maybe just a generic "###" pattern. Or, you can just tag offending messages for moderation, even if the forum is not a moderated forum (would require a little more work on the plumbing).

  • Add private forums, whereby members can send each other messages, but each member can only read messages that were specifically addressed to them. This is a handy way to encourage people to communicate with each other, while allowing them to keep their own personal e-mail address hidden from other users (which is often desirable as a means of limiting spam). To make this easier to use, whenever you see the username of someone who posted a message in a forum, that username could have a link to another page that gives you the option to send that user a private message. To ensure that she will read your message, you could add an automatic check for private messages that would occur each time a registered user logs in.

  • Implement a search feature to enable users to locate messages containing certain words or phrases.

  • Let members upload their own attachments, which would be accessed from a link in a forum message (be sure to make this an option, because some site owners may not like this idea for security and bandwidth reasons). You could allow configurable filename extensions (disallowing .exe, .bat, .vbs, and so forth, but allowing .doc, .txt, and the like) and a configurable limit on allowable file size. You might also want to force any messages containing an attachment to be moderated so that a power user can review the attachment before allowing it (this is especially important if you want to allow images to be uploaded).

There are numerous very complex and complete forums systems for ASP.NET, and many of them are free. You might want to use one of them if the simple forums module presented here doesn't meet your needs, or you might just want to study the others to get ideas for features you might want to add to your own forum module. One of the best, and most feature-rich, forums modules for ASP.NET is the Community Server, available at www.communityserver.org. This is 100% free for nonprofit sites, and fairly inexpensive for use on commercial sites. This is the same forums module used by the famous www.asp.net site, Microsoft's official ASP.NET developer site. But don't be too quick to discard the forums module developed in this chapter, because even though it's missing some of the more advanced features, it still has several big benefits, including the fact that it's already integrated with the site's common layout and membership system (while others do not, unless you modify them, as they need to be installed on a separate virtual folder that makes it more difficult to share pieces of the parent site); it uses many of the features in ASP.NET 2.0, and it is fairly easy to maintain and understand.

In the next chapter, we'll implement another common requirement in a modern, full-featured website: an e-commerce store with support for real-time electronic payments.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset