Driving the Data Bus: Chatting on Company Time--Building a VFP Chat Module 

This article may contain URLs that were valid when originally published, but now link to sites or pages that no longer exist. To maintain the flow of the article, we've left these URLs in the text, but disabled the links.

Driving the Data Bus: Chatting on Company Time—Building a VFP Chat Module

Andrew Coates

Passing data to and from applications over the network makes the synchronization of data simple. In this article, Andrew Coates uses a messaging hub server to develop a chat component that can carry more than just typed conversations between application users.

The chat system allows users across the network to exchange data in real time. Most commonly, this data consists of typed conversations, but the power of the system lies in its ability to pass other data as well. This could be in the form of audio or video or, as will be presented here, other text data that will allow the two chatting parties to retrieve the same database record by sending a primary key or keys from one to the other.

The chat system

To allow chatting, each client registers itself with a chat server as it starts up. The chat server maintains a list of connected clients and can detect disconnections automatically. If a client goes offline either by choosing to disconnect or due to network or some other failure, the server removes that client from the list. A schematic representation of the setup is shown in Figure 1.

Each client consists of a chat handler, which sends and receives the messages, and zero or more chat forms. A chat conversation is carried out between chat forms, which are created specifically for that conversation and destroyed at the conclusion of the conversation. A client may be involved in many chats simultaneously, so to ensure that messages are routed to the correct form, the chat handler employs a concept of "chat slots" or a collection of instances of the chat form so they can handle multiple chats simultaneously and not get crossed lines. A schematic of the clients, the chat handlers, and the chat forms is shown in Figure 2.

In Figure 2, there are two conversations being conducted. Client 1 is talking to client 2 and client 3. Because client 3 addresses its messages to client 1, chat slot 2, they never get mixed up with messages from client 2, which are addressed to client 1, chat slot 1.

Initiating a chat

To establish a conversation between two clients, one of the clients needs to initiate the chat. The initiating client sends a request to the intended recipient, and the recipient either accepts or declines the invitation to chat.

The process from the chat initiator's point of view is shown in Figure 3.

When a client decides to start a chat, it creates a chat form and assigns the form a slot number. Next, it sends a request to the recipient that includes the sender's ID and slot index. It waits for a response from the recipient, and if it gets the response, it shows the form and the chat begins. If it doesn't get a response, it destroys the form and tells the user that the request timed out.

Responding to a request for a chat

The process from the chat recipient's point of view is shown in Figure 4.

When the recipient receives a request to chat, it decides to either accept or decline the request. If it declines to chat, it simply sends a message back to the requester declining the invitation. If it decides to accept the invitation, it creates an instance of the chat form and assigns it to a chat slot of its own. As long as it creates the form successfully, it sends a message to the requester accepting the chat and informing it of the recipient's chat slot index. It then displays the form, and the chatting begins.

Chatting

To chat, the two clients send messages to each other addressed using the client ID and the chat slot index. When a client receives a message, it passes it to the form in the applicable chat slot, and the form displays the message.

Ending the chat

When a chat form is closed, it removes itself from the chat slot collection and sends a message to the other client that it's being closed. When a form receives a message that the other party has closed the form, it remains open so any further action can be taken by the client, such as logging the conversation to a table or other file, but it won't allow the form to send any more messages.

InterCom System

The InterCom System server (available from www.cns.nl/) is a messaging hub—that is, an application that sits somewhere on the network where all users can access it and allows applications to connect to it, register interest in certain events (subscribe), trigger events for other users, send messages to specific users, and disconnect from the hub. It sits somewhere on an IP network (note that the system is restricted to running over an IP network), and any client on the network—which could include the entire Internet—can connect to the server. Any connected client can then send a message to any other connected client. Clients can also trigger an "event," which is broadcast to all of the other connected clients that have subscribed to that event. The triggered event can have data associated with it. Therefore, clients can communicate:

  • with all other interested clients (by triggering an event to which other interested parties have subscribed);
  • with a specific client (by sending a message to a specific client ID); or
  • with the server to get information about the other clients connected to the system.

The client is in the form of an ActiveX control and can therefore be placed on a VFP form and its methods, properties, and events accessed as for any other such control. The InterCom System provides a client with three types of functionality—Notification, Messaging, and Inventory. The InterCom System is discussed in more detail in my article "Pushing the Point" in the August 1999 issue of FoxTalk.

At the time of this writing, the InterCom System costs $399 USD for a royalty-free, single-developer license. There's also an evaluation version of the system available for free download. The evaluation system is limited to a maximum of three simultaneous connections, but it's otherwise identical to the full version.

Wrapping the client

The InterCom client has one feature that makes it awkward to use in VFP. It employs arrays passed by reference to return lists of information. VFP doesn't handle this data type well (the COMARRAY() function only appears to work with COM servers created via a CREATEOBJECT call, not with ActiveX controls). To overcome this limitation, a wrapper for the ActiveX control was developed in VB. The wrapper intercepts the method calls and translates the array into a delimited string, which VFP handles very well. The wrapper class is included in the accompanying Download file.

The chat handler

The core of the chat component on each client is the chat handler. This object is instantiated when the application starts up and provides the communications link to the InterCom server. It's based on the Form base class, so it can provide a container for the ActiveX InterCom client control. The load method of the class simply checks that the wrapped InterCom client is installed on the system. The init method accepts a reference to the calling object (for callbacks) and the name or IP address of the InterCom server machine. It then attempts to connect to the server and returns a logical indicating success.

To initiate a chat, the application calls the chat handler's StartChat() method, passing the descriptor of the client with whom the chat is requested. The sequence of events shown in Figure 3 then begins. The code for the StartChat() method is shown in Listing 1.

Listing 1. The chat handler's StartChat() method.

  lParameters tcUser, tnCompanyID, tnContactID

local lnChatSlot, lnCompanyID, lnContactID
lnCompanyID = iif(type('tnCompanyID') # "N" ;
   or isnull(tnCompanyID), 0, tnCompanyID)
lnContactID = iif(type('tnContactID') # "N" ;
   or isnull(tnContactID), 0, tnContactID)
lnChatSlot = thisform.GetNextFreeSlot()

* Find the target user's chatID
local lcClientList
lcClientList = ""
thisform.oleInterComClient.FindClients(cpDescriptor, ;
   tcUser, @lcClientList)

local lnMatchingClients
lnMatchingClients = val(mLine(lcClientList, 1))

if lnMatchingClients = 0
  thisform.aChatSessions[lnChatSlot] = .null.
  messagebox("Could not locate chatID for " + tcUser, ;
    MB_ICONEXCLAMATION, ;
  thisform.Caption)
  return .f.
endif

local lnRemoteClientID
lnRemoteClientID = val(mLine(lcClientList, 2))

thisform.aChatSessions[lnChatSlot] ;
  = create("ChatForm", CHAT_CALLER, lnChatSlot, ;
    this, lnCompanyID, lnContactID, tcUser, ;
    lnRemoteClientID)

if vartype(thisform.aChatSessions[lnChatSlot]) # "O"
  thisform.aChatSessions[lnChatSlot] = .null.
  messagebox("Could not initialise chat interface form", ;
    MB_ICONEXCLAMATION, thisform.Caption)
endif

In the example shown here, there's the facility to pass two additional pieces of information with the chat request—a Company ID and a Contact ID. In this application, these are primary keys to two main tables. Passing these keys to the other client allows that client to retrieve the data about which the initiator wishes to chat, perhaps even displaying information from the relevant rows in the table as part of the chat dialog box.

After checking the validity of these key parameters, the method requests a list of clients matching the descriptor from the InterCom server. The request is rejected if no clients match. If there's a matching client, the method obtains a chat slot and populates it with an instance of the chat form. The chat form sends a message to the remote client's chat handler requesting the chat and sets a timer that will fire if the operation times out. It's then up to the other client to respond within the timeout period.

The chat handler form class contains only one (significant) control—the InterCom client control. That control has only one overridden event—the one that processes incoming messages, which is called, originally enough, the OnMessage() event. The event code simply directs the message to the appropriate chat handler method. The OnMessage() event code is shown in Listing 2.

Listing 2. The chat handler's InterCom client OnMessage() event code.

  *** ActiveX Control Event ***
LPARAMETERS client, subject, data

* subject line contains type of message
local lnMessageType
lnMessageType = val(subject)

do case
case lnMessageType = CHAT_REQUEST
  thisform.HandleRequest(client, data)
case lnMessageType = CHAT_ACCEPT
  thisform.HandleAccept(client, data)
case lnMessageType = CHAT_MESSAGE
  thisform.HandleMessage(client, data)
case lnMessageType = CHAT_DISCONNECT
  thisform.HandleDisconnect(client, data)
endcase

The important point to note from the OnMessage() event code is that the type of message being sent is stored in the message subject. All the OnMessage() handler does is work out what kind of message is being sent by reading the subject and then route the message to the appropriate message-handling method of the chat handler object.

The chat handler object has four main message-handling methods:

  • HandleRequest()—Handles an invitation to chat from a remote client.
  • HandleAccept()—Handles the acceptance of an invitation to chat.
  • HandleMessage()—Handles a standard message (usually a line of typed conversation).
  • HandleDisconnect()—Handles the message sent by the other party when they terminate the chat session.

HandleRequest()

The HandleRequest() method is fired on the remote client when an invitation to chat is received. The method initiates the sequence shown in Figure 4. The code for the HandleRequest() method is shown in Listing 3.

Listing 3. The chat handler's HandleRequest() method.

  lParameters tnClient, tcData

* break data up into its constituent parts
local lnRemoteChatSlot, lnCompanyID, lnContactID, ;
  lcRemoteClientInitials, lnMemoWidth
lnMemoWidth = set('memowidth')
set memowidth to 1024
lnRemoteChatSlot = val(mline(tcData, 1))
lnCompanyID = val(mline(tcData, 2))
lnContactID = val(mline(tcData, 3))
lcRemoteClientInitials = mline(tcData, 4)
set memowidth to lnMemoWidth

if messagebox("A chat is being requested by " ;
  + lcRemoteClientInitials ;
  + CR + "Answer?" + CR + ttoc(datetime()), ;
  MB_ICONQUESTION + MB_YESNO + MB_SYSTEMMODAL, ;
  "Incoming Chat") = IDYES

  lnChatSlot = thisform.GetNextFreeSlot()

  thisform.aChatSessions[lnChatSlot] ;
    = create("ChatForm", CHAT_RECEIVER, ;
    lnChatSlot, thisform, lnCompanyID, lnContactID,;
    lcRemoteClientInitials, tnClient, lnRemoteChatSlot)

  * if the form's instantiated sucessfully, it 
  * accepts the chat request; we just need to 
  * handle failures here
  if vartype(thisform.aChatSessions[lnChatSlot]) # "O"
    * we failed to instantiate the chat form, so 
    * attempt to tell the caller that first,
    * make sure the chat form slot is set to .null.
    thisform.aChatSessions[lnChatSlot] = .null.

    * now, send a message to the waiting client
    local lcMessage
    lcMessage = transform(lnRemoteChatSlot)
    lcMessage = lcMessage + CR + '0'
    lcMessage = lcMessage + CR + transform(CHAT_INITFAILURE)
    thisform.oleInterComClient.SendMessage(tnClient, ;
      transform(CHAT_ACCEPT), lcMessage)

  endif

else

  * send a call rejected message to the waiting client

  local lcMessage
  lcMessage = transform(lnRemoteChatSlot)
  lcMessage = lcMessage + CR + '0'
  lcMessage = lcMessage + CR + transform(CHAT_REJECTED)
  thisform.oleInterComClient.SendMessage(tnClient, ;
    transform(CHAT_ACCEPT), lcMessage)

endif

The HandleRequest() method starts by reading the message and breaking it down into its component parts. Each part is sent in the message's data property on a separate line. The handler prompts the user, inviting them to accept the chat, and if the user rejects the invitation, it simply sends a message back to the initiator rejecting the request. If the request is accepted, the chat handler finds the next available free chat slot (or creates a new slot if there isn't one free already). It then populates that slot with a new instance of the chat form. If it's instantiated successfully, the chat form handles the notification of the acceptance of the chat. If it's not instantiated successfully, the chat handler sends a message notifying the initiator that the chat was accepted, but that technical difficulties prevented it from occurring.

HandleAccept()

The HandleAccept() method is fired on the initiating chat client when it receives acceptance of an invitation to chat from the remote client. The code for the HandleAccept() method is shown in Listing 4.

Listing 4. The chat handler's HandleAccept() method.

  LPARAMETERS tnClient, tcData

* break data up into its constituent parts
local lnLocalChatSlot, lnRemoteChatSlot, ;
  lnAcceptanceStatus, lnMemoWidth
lnMemoWidth = set('memowidth')
set memowidth to 1024
lnLocalChatSlot = val(mline(tcData, 1))
lnRemoteChatSlot = val(mline(tcData, 2))
lnAcceptanceStatus = val(mline(tcData, 3))
set memowidth to lnMemowidth

local llContinue
llContinue = .f.

do case
case lnAcceptanceStatus = CHAT_OK
  llContinue = .t.
case lnAcceptanceStatus = CHAT_REJECTED
  messagebox("Your request for a chat was rejected", ;
    MB_ICONINFORMATION, thisform.caption)
case lnAcceptanceStatus = CHAT_INITFAILURE
  messagebox("Your request for a chat was accepted, " ;
    + "but a system error prevented the remote window " ;
    + "from being displayed", ;
    MB_ICONINFORMATION, thisform.caption)
endcase

if llContinue
  thisform.aChatSessions[lnLocalChatSlot].;
    nRemoteClientSlot = lnRemoteChatSlot
  thisform.aChatSessions[lnLocalChatSlot].;
    tmrCountDown.interval = 0
  thisform.aChatSessions[lnLocalChatSlot].show()
else
  thisform.aChatSessions[lnLocalChatSlot].release()
  thisform.aChatSessions[lnLocalChatSlot] = .null.
endif

The HandleAccept() method begins by reading the constituent parts of the message from the data parameter. It then checks to see whether the chat was accepted or rejected, either because the remote user declined or technical difficulties prevented the chat from occurring. If it was accepted, the remote chat slot is assigned to a property of the appropriate chat form, the timeout timer is disabled, and the chat form is displayed—everyone is ready to chat! If it's rejected, a message is displayed to that effect, and the chat form is released and the chat slot cleared.

HandleMessage()

The HandleMessage() method is fired on receipt of a standard message—the type of message that's passed back and forth between clients during the course of a chat. The code for the HandleMessage() method is shown in Listing 5.

Listing 5. The chat handler's HandleMessage() method.

  LPARAMETERS tnClient, tcData

* break data up into its constituent parts
local lnLocalChatSlot, lcMessagetext, lnMemoWidth
lnMemoWidth = set('memowidth')
set memowidth to 1024
lnLocalChatSlot = val(mline(tcData, 1))
lcMessageText = mline(tcData, 2)

set memowidth to lnMemowidth

if vartype(thisform.aChatSessions[lnLocalChatSlot]) = "O"
  thisform.aChatSessions[lnLocalChatSlot].; 
    ReceiveMessage(lcMessageText)
else
endif

The HandleMessage() method simply breaks out the chat slot (so it knows where to send the message) and sends the text of the message to the appropriate chat form for handling.

HandleDisconnect()

The HandleDisconnect() method is fired when the chat handler receives notice that the remote client has disconnected from the chat. The code for the HandleDisconnect() method is shown in Listing 6.

Listing 6. The chat handler's HandleDisconnect() method.

  LPARAMETERS tnClient, tcData

* break data up into its constituent parts
local lnLocalChatSlot, lcMessagetext, lnMemoWidth
lnMemoWidth = set('memowidth')
set memowidth to 1024
lnLocalChatSlot = val(mline(tcData, 1))

set memowidth to lnMemowidth

if vartype(thisform.aChatSessions[lnLocalChatSlot]) = "O"
  thisform.aChatSessions[lnLocalChatSlot].;
    HandleDisconnect()
else
endif

The HandleDisconnect() method simply breaks out the chat slot (so it knows where to send the message) and fires the HandleDisconnect() of the appropriate chat form.

The chat form

The other half of the chat client component is the chat form itself. This is the visible manifestation of the chat component where the user types messages and reads the messages typed by the other user. The chat form in our sample chat app is shown in Figure 5.

The chat form handles much of the communication once the chat handler has established the conversation. To do this, it uses the following key methods:

  • Init()—Responsible for notifying the remote client of some pertinent details and for actually displaying the form.
  • HandleDisconnect()—Responsible for handling the notification that the remote client has ended the chat session.
  • ReceiveMessage()—Responsible for displaying the text of a message received from the remote client.
  • SendDisconnect()—Responsible for notifying the remote client that the local client is terminating the chat session.
  • SendMessage()—Responsible for sending a line of text to the remote client.

Init()

The Init() method has two different behaviors, depending on whether the chat form is being instantiated as a chat initiator or a chat receiver. In the end, the functionality of each type of chat form is identical, but the process of creating the form differs depending on its role. The code for the Init() method is shown in Listing 7.

Listing 7. The chat form's Init() method.

  lParameters tnInitType, tnLocalChatSlot, toCallingForm, ;
  tnCompanyID, tnContactID, tcRemoteClientInitials, ;
  tnRemoteClientID, tnRemoteClientSlot

local llReturnValue
llReturnValue = .f.

with thisform
  .oCallingForm = toCallingForm
  .nLocalChatSlot = tnLocalChatSlot
  .nCompanyID = tnCompanyID
  .nContactID = tnContactID
  .nRemoteClientID = tnRemoteClientID
  .cRemoteClientInitials = tcRemoteClientInitials

  .Caption = "Chatting to " ;
     + alltrim(tcRemoteClientInitials) + " : " + ;
    .oCallingForm.Caption


  * disable the buttons that don't apply to this session
  .SetButtonsState()

  if tnInitType = CHAT_RECEIVER

    .nRemoteClientSlot = tnRemoteClientSlot

    * if we're the receiver, send a message to the 
    * client saying that we're ready to chat
    local lcMessage
    lcMessage = transform(.nRemoteClientSlot)
    lcMessage = lcMessage + CR + transform(.nLocalChatSlot)
    lcMessage = lcMessage + CR + transform(CHAT_OK)
    .oCallingForm.oleInterComClient.SendMessage(.nRemoteClientID, ;
      transform(CHAT_ACCEPT), lcMessage)

    .show()
    llReturnValue = .t.

  else

    * if we're the caller, send a message to the client 
    * saying that we want to chat
    local lcMessage
    lcMessage = transform(.nLocalChatSlot)
    lcMessage = lcMessage + CR + transform(.nCompanyID)
    lcMessage = lcMessage + CR + transform(.nContactID)
    lcMessage = lcMessage + CR ;
      + .oCallingForm.oCallingForm.cInitials
    .oCallingForm.oleInterComClient.SendMessage(;
     .nRemoteClientID, ;
      transform(CHAT_REQUEST), lcMessage)
    llReturnValue = .t.

    * start the timer to see if they respond
    .tmrCountDown.Interval = 15000
  endif

endwith

return llReturnValue

The Init() method accepts quite a list of parameters. The first is the mode in which this form is being instantiated. The allowable values are CHAT_RECEIVER or CHAT_CALLER (defined in chat.h). This information is used to determine the behavior of the object later in the Init() process. The next parameter refers to the local chat handler's chat slot to which this chat form has been assigned. Next, a reference to the chat handler object is passed so the chat form can access its properties and methods. The next two parameters are additional data used in this sample application to pass the primary keys of two sample tables.

The keys can be used by the form to display the data applicable to the chat. The descriptor for the remote client is the next thing to be passed. This will be displayed in the form's caption so the user can tell who this chat session is with. Finally, if the chat slot for this chat on the remote client is known, this is passed as the last parameter. If this is the chat receiver, the remote chat slot will be known, but if this is the chat initiator, the remote chat slot will be passed back as part of the chat acceptance message.

The parameters are assigned to properties of the form for later use, and the caption is set. Next, an application-specific method, SetButtonState(), is called. In this case, this method is designed to allow the retrieval of the linked data if primary keys have been passed to the form.

Now the code forks. If the form is a chat recipient, it sends a message back to the chat initiator, accepting the chat and telling the initiator the chat slot ID that's been assigned for use on the chat receiver, and makes the form visible. If the form is a chat initiator, it sends a message to the chat receiver requesting the chat and sets a timer so the chat requester doesn't wait forever for a response.

HandleDisconnect()

The HandleDisconnect() method informs the user that the remote user has disconnected and sets a local property of the chat form to indicate that the chat is no longer live. It doesn't close the form, as the local user might wish to review the contents of the chat before closing the form. The code for the HandleDisconnect() method is shown in Listing 8.

Listing 8. The chat form's HandleDisconnect() method.

  messagebox("The remote chatter has disconnected " ;
  + "from this session", ;
  MB_ICONINFORMATION, this.caption)
this.lDisconnected = .t.

ReceiveMessage()

The ReceiveMessage() method adds a line of text to the list box chat log. The code for the ReceiveMessage() method is shown in Listing 9.

Listing 9. The chat form's ReceiveMessage() method.

  lParameters tcMessage

thisform.tmrCountDown.Interval = 0
thisform.lstChatText.AddItem(tcMessage)
thisform.lstChatText.Value ;
  = thisform.lstChatText.ListCount

SendDisconnect()

The SendDisconnect() method sends a line of text from the local client to the remote client. It also displays the line in the list box chat log for later reference. The code for the SendDisconnect() method is shown in Listing 10.

Listing 10. The chat form's SendDisconnect() method.

  with thisform
  local lcMessage
  lcMessage = transform(.nRemoteClientSlot)
  .oCallingForm.oleInterComClient.SendMessage(;
    .nRemoteClientID, ;
    transform(CHAT_DISCONNECT), lcMessage)
endwith

The SendDisconnect() method builds a message string that simply consists of the chat slot ID on the remote client. It then calls the SendMessage() method of the chat handler's InterCom client control. The message is addressed to the remote client's client ID; it has a subject of CHAT_DISCONNECT (defined in chat.h), and the text of the message consists of the remote chat slot ID.

SendMessage()

The SendMessage() method sends a line of text from the local client to the remote client. It also displays the line in the list box chat log for later reference. The code for the SendMessage() method is shown in Listing 11.

Listing 11. The chat form's SendMessage() method.

  lParameters tcMessage

with thisform
  local lcMessage
  lcMessage = transform(.nRemoteClientSlot)
  lcMessage = lcMessage + CR + tcMessage
  .oCallingForm.oleInterComClient.SendMessage(;
    .nRemoteClientID, ;
    transform(CHAT_MESSAGE), lcMessage)

  thisform.lstChatText.AddItem(tcMessage)
  thisform.lstChatText.Value ;
    = thisform.lstChatText.ListCount

endwith

The SendMessage() method builds a message string that consists of the chat slot ID on the remote client and the text of the message. It then calls the SendMessage() method of the chat handler's InterCom client control. The message is addressed to the remote client's client ID; it has a subject of CHAT_MESSAGE (defined in chat.h), and the text of the message consists of the remote chat slot ID and the line of text to be displayed. Finally, the line of text is added to the chat log list box on the local chat form.

Sample code

To demonstrate the use of the chat component, you'll need the following:

  • The InterCom System server installed somewhere on a TCP/IP network. For the purposes of this exercise, it's assumed that the server is installed at IP address 192.168.0.1.
  • The InterCom System client installed on all machines that are going to act as chat clients.
  • The InterCom client wrapper (available in the Download file) installed and registered on all machines that are going to act as clients.
  • An instance of VFP for each chat client (either running on the same machine or on separate machines). Note that the evaluation version of the InterCom server only allows three concurrent connections. The full version has no such limitations.
  • The class library CHAT.VCX (available in the Download file) extracted to a commonly accessible location. For the purposes of this exercise, it's assumed that the path to the class library is \\SERVER\UTILS\VFPCHAT.

Once these steps are complete, issue the following commands from the VFP command window for each instance of VFP. Substitute a unique number for n.

  cd \\server\utils\vfpchat
set classlib to chat
oTestChat = createobject('testchat', 'Clientn')
oChatHandler = createobject('ChatHandler', ;
  oTestChat, '192.168.0.1')

Next, on one of the clients, enter the following command, where n is the number of another client:

  oChatHandler.StartChat('Clientn')

The second client should pop up a message box asking whether to accept the chat, and, if the chat is accepted, a chat form should be displayed on both the caller and receiver. Messages typed on one client should appear on the other (after the Enter key is pressed).

Conclusion

Being able to communicate with other users of an application in real time and with the facility to link the conversation with data from the application adds another powerful resource to the programmer's toolbox. The techniques presented in this article combine a commercially available solution to inter-application communication with VFP's data-handling and UI.

To find out more about FoxTalk and Pinnacle Publishing, visit their website at http://www.pinpub.com/html/main.isx?sub=57

Note: This is not a Microsoft Corporation website. Microsoft is not responsible for its content.

This article is reproduced from the February 2001 issue of FoxTalk. Copyright 2001, by Pinnacle Publishing, Inc., unless otherwise noted. All rights are reserved. FoxTalk is an independently produced publication of Pinnacle Publishing, Inc. No part of this article may be used or reproduced in any fashion (except in brief quotations used in critical articles and reviews) without prior consent of Pinnacle Publishing, Inc. To contact Pinnacle Publishing, Inc., please call 1-800-493-4867 x4209.

© Microsoft Corporation. All rights reserved.