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.
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.
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.
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.
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).
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):
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.
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.
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.
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.
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/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.
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.
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
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.
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
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.
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.
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.
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:
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 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.
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 ImageButton
s 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 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.
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 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.
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 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.
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}" /> <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;" />
</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 " "
, 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
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" />
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.
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.