Tag: FluidFramework

Use FluidFramework in a Microsoft Teams app

Use FluidFramework in a Microsoft Teams app

While producing my last sample on Microsoft teams meeting app I discovered the challenge to synchronize real time data. This led to my exploration of Microsoft’s FluidFramework. While in previous parts I explained how to simply deal with it in the frontend (part 1) and how to have a reliable storage in the backend (part 2) now it’s finally time to put the pieces together and implement everything in a (my former) Teams app. So grab your popcorn 🍿 and let’s vote for favorite movies again.

Series

Content

Setup

To set up the app we need to scaffold a Teams tab application. This time it needs to include SSO. Of course the manifest needs to consider Teams meeting app settings like described in my previous sample.

Teams Meeting tab setup in yo Teams with SSO

Additionally for Microsoft FluidFramework and Azure Fluid Relay service the following npm packages need to be installed:

npm i fluid-framework @fluidframework/azure-client @fluidframework/test-client-utils

Configuration

AzureConnectionConfig

Since FluidFramework version 1 there is a distinction between a local and a remote configuration. While the local one is quite straightforward the remote one needs parameters from the Azure Fluid Relay service.

const useAzure = true; // | false
const AzureLocalConnection: AzureLocalConnectionConfig = {
  type: "local",
  tokenProvider: new InsecureTokenProvider("c51b27e2881cfc8d8101d0e1dfaea768", { id: userID }), // Problematic to have secret here in client-side code
  endpoint: process.env.REACT_APP_REDIRECTURI!,
};
const AzureRemoteConnection: AzureRemoteConnectionConfig = {
  type: "remote",
  tenantId: process.env.REACT_APP_TENANT_ID!,
  tokenProvider: new AzureFunctionTokenProvider(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", { userId: userID, userName: "Test User" }),
  endpoint: process.env.REACT_APP_ORDERER!
};
export const connectionConfig: AzureClientProps = useAzure ? { connection: AzureRemoteConnection} : { connection: AzureLocalConnection } ;
Azure Fluid Relay – Access information

App installation

While in the previous posts the containerId was stored in the app url once one was established (and we did not really care how and where to persist it btw…) which looked liked this:

http://localhost:3000/#a31ffdc3-f230-48fb-83de-38632a30c46c

In Teams this needs to be handled differently. The Microsoft tutorial suggests to establish a container during app setup (installation) and persist the containerId into app’s contentUrl.
The disadvantage here is that in case of an app reconfiguration any existing containerId might get lost. But here is a more flexible way. As this app needs an app configuration (for the movie urls) anyway let’s put the containerId there. Furthermore I optionally allow to “reset” it. Maybe someone wants to change movies AND clear existing votes.

Teams app configuration with urls and option to reset container (of votes)
const saveConfig = async (idToken: string, saveEvent: pages.config.SaveEvent) => {
    if (reset) {
      setContainerId("");
    }
    const currentContainerId = await getFluidContainerId(context?.user?.userPrincipalName!, idToken, containerId);
    const host = "https://" + window.location.host;
    pages.config.setConfig({
      contentUrl: host + "/voteMovieFluidTab/?" + 
          containerIdQueryParamKey + "=" + currentContainerId +
          "&name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      websiteUrl: host + "/voteMovieFluidTab/?" + 
          containerIdQueryParamKey + "=" + currentContainerId +
          "&name={loginHint}&tenant={tid}&group={groupId}&theme={theme}",
      suggestedDisplayName: "Vote Movie Fluid",
      removeUrl: host + "/voteMovieFluidTab/remove.html?theme={theme}",
      entityId: entityId.current
    }).then(() => {
      Axios.post(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meetingID.current}`,
                { config: { movie1url: movieRef1.current, movie2url: movieRef2.current, movie3url: movieRef3.current, containerId: currentContainerId }});
      saveEvent.notifySuccess();
    });
  };

In code at first any existing containerId is reset to blank if the user clicked the corresponding Toggle. Next a container is either created or a connection to a given one is established. Then the containerId is written to contentUrl and websiteUrl as the Microsoft tutorial suggests. But finally the containerId is also written to the app configuration store together with the movie urls. From here it can also be easily retrieved while next time the settings page is opened for re-configuration and in case the user does not want a reset any given containerId can be kept.

const loadConfig = async (meeting: string) => {
    Axios.get(`https://${process.env.PUBLIC_HOSTNAME}/api/config/${meeting}`).then((response) => {
        const config = response.data;
        setMovie1(config.movie1url);
        setMovie2(config.movie2url);
        setMovie3(config.movie3url);
        setContainerId(config.containerId);
    });
  };
useEffect(() => {
    if (context) {
      let meeting = "";
      meeting = context.meeting?.id!;
      meetingID.current = meeting;
      loadConfig(meeting);
      pages.config.registerOnSaveHandler(onSaveHandler);
      pages.config.setValidityState(true);
      app.notifySuccess();
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [context]);

SSO

For a secure connection to the Azure Fluid Relay service a Token service provider represented by an Azure function is needed. I already explained this Azure function in the previous part and you can also refer to it in my GitHub repository.

If this Azure function requires user authentication the teams app needs an identity token to access this Azure function. To get this identity token an application ID is needed. This application ID comes from the identity provider of the Azure function. In the previous part of this series the configuration of the Azure function identity provider was already introduced. If the identity provider application is configured as needed for Teams tab SSO additionally two steps are needed. But first in short the regular SSO steps here. This needs to be applied to the app registration of the Azure Function’s identity provider:

  • Expose an API and give the Api URI a name like api://xxxx.ngrok.io/<Your App ID> (xxxx depends on your current ngrok Url)
  • Set scope name to access_as_user and provide Admin & User messages
  • Add Teams Client 1fec8e78-bce4-4aaf-ab1b-5451cc387264 and Teams Web Client 5e3ce6c0-2b1f-4285-8d4b-75ee78787346 IDs under “Add a client application”

Additionally the teams app URL needs to be added to the CORS settings of the Azure function. And the app ID URI needs to be added as allowed token audience to the identity provider.

Teams app url added to CORS of TokenServiceProvider Azure Function
AppIdURI as allowed token audience for Azure Function’s identity provider

Having everything configured the implementation of SSO looks like the given example from the configuration page:

const secureAccess = true; // false
…
if (secureAccess) {
      if (inTeams === true) {
        authentication.getAuthToken({
            resources: [process.env.TAB_APP_URI as string],
            silent: false
        } as authentication.AuthTokenRequestParameters).then(token => {
          saveConfig(token, saveEvent)
            
        }).catch(message => {
          app.notifyFailure({
              reason: app.FailedReason.AuthFailed,
              message
          });
        });
      }
    }
    else {
      saveConfig("", saveEvent);
    }  
  };
  const saveConfig = async (idToken: string, saveEvent: pages.config.SaveEvent) => {
    if (reset) {
      setContainerId("");
    }
    const currentContainerId = await getFluidContainerId(context?.user?.userPrincipalName!, idToken, containerId);
…
}

If the app is configured to use secure access an ID token is generated and used for establishing the fluid container connection. If not the token is handed in as blank. As known from the previous part in case the fluid connection function receives a token a custom AzureFunctionTokenProviderSec is used.

if (authToken !== "") {
    connectionConfig.connection.tokenProvider = new AzureFunctionTokenProviderSec(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", authToken, { userId: userID, userName: "Test User" });
  }

Client implementation

Establish Fluid Relay connection

In the parent client-side JSX component the connection to the Fluid backend container represented by a SharedMap is established.

const setFluidAccess = (token: string, containerId: string) => {
    getFluidContainer(context?.user?.userPrincipalName!, token, containerId)
      .then((fluidContainer) => {
        if (fluidContainer !== undefined) {
          const sharedVotes = fluidContainer.initialObjects.sharedVotes as SharedMap;
          setFluidContainerMap(sharedVotes);
        }
      });
 };

Although FluidFramework can deal with different objects a SharedMap is ideal here. As in this case there’s a need to store the following four values:

// Initialize votes
const sharedVotes = container.initialObjects.sharedVotes as SharedMap;
sharedVotes.set("votes1", 0);
sharedVotes.set("votes2", 0);
sharedVotes.set("votes3", 0);
sharedVotes.set("votedUsers", "");

Voting

Having a connection to the SharedMap coming from the parent component the voting is quite easy. Once the Vote button is clicked the corresponding vote() action is called. This writes an increased value to the SharedMap. An event receiver synchronizes this with the state variable responsible for the UI text value. While the write operation to the SharedMap automatically takes care for correspondence/sync with other clients.

const vote = async () => {    
    let votedUsers = props.votingMap.get("votedUsers");
    votedUsers += `;${props.userID}`;
    props.votingMap.set("votedUsers", votedUsers);
  };
  useEffect(() => {
    evalVotable();
  }, []);

  React.useEffect(() => {
    const updateVotes = () => {
      setVotes1(props.votingMap.get("votes1")!);
      setVotes2(props.votingMap.get("votes2")!);
      setVotes3(props.votingMap.get("votes3")!);
      evalVotable();
    };

    props.votingMap.on("valueChanged", updateVotes);

    return () => {
      props.votingMap.off("valueChanged", updateVotes);
    };
  });
....
{votable &&
              <div>
                <Button className="voteBtn" onClick={() => { props.votingMap.set("votes1", votes1! + 1); vote(); }}>Vote Movie 1</Button>
              </div>}

Result and summary

With Microsoft FluidFramework and Azure Fluid Relay service the given Teams application still looks the same as introduced in my previous post.

SidePanel movie voting
StageView watch most voted movie

But with some configuration effort and, depending on your security requirements, a reduced amount of lines of code it is achievable having a reliable and scalable enterprise-ready real time data synchronization.

Nevertheless establishing a secure access to Azure Fluid Relay service could still be facilitated by Microsoft with more advanced serverless components. For further reference check my GitHub repositories with the current solution, the Secure Token Service provider and for comparison my former solution.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
FluidFramework and Azure Fluid Relay service

FluidFramework and Azure Fluid Relay service

Back on using Microsoft’s FluidFramework for using synchronized real time data in collaborative applications. Additionally to what I covered in the first post here we will consider more enterprise-ready storage options for handling our backend data.

In fact we are going to replace one single line of code from part I which establishes the client.

const client = new TinyliciousClient();

And yes it’s about replacing one single line with a bunch of code but of course we have to talk about reliability, enterprise-readiness and last not least security. The answer will be the Azure Fluid Relay service which is in public preview right now at the time of writing this post.

Series

Content

Azure Fluid Relay

While in part one our server side component tinylicious was working out of the box we now have to deploy an Azure Fluid Relay service. As for typical Azure resources there’s nothing more than selecting a subscription, resource group and a name.

Create an Azure Fluid Relay

Client access

To establish a connection at first an azure-client package has to be installed.

npm i @fluidframework/azure-client @fluidframework/test-client-utils

For access to the Azure Fluid Relay service a shared key is needed. This can be retrieved from the access keys section.

Insecure

The easiest but not very secure way is to use the InsecureTokenProvider. As needed this will create a Json Web Token (JWT) for access to Azure Fluid Relay service.

export const connectionConfig: AzureClientProps = { connection: {
    tenantId: "34d381d*-****-****-****-*******60f90",
    tokenProvider: new InsecureTokenProvider("c51b27e2881cfc8d8101d0e1dfaea768", { id: userID }),
    orderer: "https://alfred.westeurope.fluidrelay.azure.com",
    storage: "https://historian.westeurope.fluidrelay.azure.com",
}} ;
const client = new AzureClient(connectionConfig);

While in line 2 the Tenant Id from above’s screenshot is used in line 3 it’s the Primary Key as shared secret. A userID is used as a variable as later a real login is established for a different reason. But here also a dummy user could be used.

Azure Token serverless

The disadvantage of the previous alternative is that the shared secret is published within client-side code. The better and more enterprise-ready solution is to retrieve the Json Web Token (JWT) from an Azure Function. So the shared secret is held in the Azure Function server-side.

Except the shared secret the Microsoft tutorial “How to implement the service token provider” handles the other variables as parameters. In this implementation I put some of them to the configuration or even hardcoded such as the tenant ID and the scope. This helps for simplicity reasons but of courses does not enable a more flexible or multi-tenant scenario. The main function code looks like this:

import { AzureFunction, Context, HttpRequest } from "@azure/functions"
import { ScopeType } from "@fluidframework/azure-client";
import { generateToken } from "@fluidframework/azure-service-utils";
import { getSecret } from "./keyVault";
const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
  const tenantId = process.env.TENANTID;
  const documentId = (req.query.documentId || (req.body && req.body.documentId)) as string | undefined;
  const userId = (req.query.userId || (req.body && req.body.userId)) as string;
  const userName = (req.query.userName || (req.body && req.body.userName)) as string;
  const scopes = [ScopeType.DocRead, ScopeType.DocWrite, ScopeType.SummaryWrite]; // "doc:read", "doc:write", "summary:write"
  if (!tenantId) {
    context.res = {
      status: 400,
      body: "No tenantId provided in query params",
    };
    return;
  }
  const key = await getSecret("AzureFluidRelay"); // Name of KeyVault secret
  if (!key) {
    context.res = {
      status: 404,
      body: `No key found for the provided tenantId: ${tenantId}`,
    };
    return;
  }
  let user = { name: userName, id: userId };
  // Will generate the token and returned by an ITokenProvider implementation to use with the AzureClient.
  const token = generateToken(
    tenantId,
    key,
    scopes ?? [ScopeType.DocRead, ScopeType.DocWrite, ScopeType.SummaryWrite],
    documentId,
    user
  );
  context.res = {
    status: 200,
    body: token
  };
};
export default httpTrigger;

For a full solution running at the point of writing, refer to my GitHub repo for this Azure Function. Also as the referred Microsoft tutorial was not up to date and having several errors / inconsistencies at the same point of time.

Testing

As the Azure Function runs anonymously it can simply be tested:
Run your function Url with following query parameters
https://<YourFunctionUrl>.azurewebsites.net/api/FluidTokenProvider?userName=markus&userId=markus@mmsharepoint.onmicrosoft.com&documentId=a0004aeb-272a-4dc2-b28b-51830cef61d7

This might produce a response like the following:

{
  "alg": "HS256",
  "typ": "JWT"
}.{
  "documentId": "a0004aeb-272a-4dc2-b28b-51830cef61d7",
  "scopes": [
    "doc:read",
    "doc:write",
    "summary:write"
  ],
  "tenantId": "34d381d*-****-****-****-*******60f90",
  "user": {
    "name": "markus",
    "id": "markus@mmsharepoint.onmicrosoft.com"
  },
  "iat": 1654865714,
  "exp": 1654869314,
  "ver": "1.0",
  "jti": "b21cd59f-3efa-4cc0-a8fd-6cd61f5429df"
}.[Signature]

Consume it

As now the Azure function is running anonymously it simply can be consumed by our existing client-side code. This looks like the following:

export const connectionConfig: AzureClientProps = { connection: {
    tenantId: process.env.REACT_APP_TENANT_ID!,
    tokenProvider: new AzureFunctionTokenProvider(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", { userId: userID, userName: "Test User" }),
    orderer: process.env.REACT_APP_ORDERER!,
    storage: process.env.REACT_APP_STORAGE!,
}}
const client = new AzureClient(connectionConfig);

In line 3 there is the Azure Function url from above coming from environment variables now. documentId is missing here, this will automatically be added by the corresponding AzureClient methods (getContainer(…) or createContainer() ) as the documentId is the same like the id of the container. userID (and also the optional userName could) is handled like above coming from a user login.

Azure Function and user authentication

Authentication against Azure Fluid Relay service always works with a shared secret. To implement user authentication you can use the workaround that the user authentication works against the Azure function which then will provide shared secret only to authenticated and authorized users. Two things need to be considered for that. At first the Azure Function needs to be configured to only accept calls from authenticated users. Next the client-side call needs to be executed in an authenticated manner. To configure the Azure Function switch to the authentication tab and require authentication while adding Microsoft as an identity provider.

Configure Azure Function authentication
Add Microsoft Azure AD as authentication provider

Next the created corresponding app registration needs to be configured. From the authentication tab click the app registration under Microsoft identity provider:

Microsoft identity provider of Azure Function authentication

What’s needed is a single page application with a corresponding redirect URI. In the given example only with a local test URL:

Single page application platform configuration

Now we can login to our application and use the received identity token to authenticate against our AzureTokenProvider function. Microsoft’s sample AzureTokenProvider does not implement authentication but we can take the open source code and further adjust it. The sample code can be found in Microsoft’s tutorial which simply can be enhanced by additionally accepting an identity token and use that to authenticate against the Azure Function:

export class AzureFunctionTokenProviderSec implements ITokenProvider {
    constructor(
        private readonly azFunctionUrl: string,
        private readonly authToken: string,
        private readonly user?: Pick,        
    ) { }
    public async fetchOrdererToken(tenantId: string, documentId?: string): Promise {
        return {
            jwt: await this.getToken(tenantId, documentId),
        };
    }
    public async fetchStorageToken(tenantId: string, documentId: string): Promise {
        return {
            jwt: await this.getToken(tenantId, documentId),
        };
    }
    private async getToken(tenantId: string, documentId: string | undefined): Promise {
        const response = await axios.get(this.azFunctionUrl, {
            headers: {
                Authorization: `Bearer ${this.authToken}`
            },
            params: {
                tenantId,
                documentId,
                userId: this.user?.userId,
                userName: this.user?.userName,
                additionalDetails: this.user?.additionalDetails,
            },
        });
        return response.data as string;
    }
}

So the difference can be found in line 4 and line 19-21. The constructor accepts an identity token and the getToken function uses it for authentication. Another difference now can be found in the utils class. If the getFluidContainer function receives an identity token It will simply exchange the AzureFunctionTokenProvider.

export async function getFluidContainer(userId: string, authToken?: string): Promise {
  userID = userId;
  if (authToken !== undefined) {
    connectionConfig.connection.tokenProvider = new AzureFunctionTokenProviderSec(process.env.REACT_APP_AZURETOKENURL + "/api/FluidTokenProvider", authToken, { userId: userID, userName: "Test User" });
  }  
  const client = new AzureClient(connectionConfig);
  let containerId: string = window.location.hash.substring(1);
  if (!containerId) {
    containerId = await createContainer(client);
    window.location.hash = containerId;
  }
  const container = await getContainer(client, containerId);
  return container;
};

So how do we get this identity token? In our app there is a specific login component. For this login component I am using MSAL.js 2.0 (npm install @azure/msal-browser) which I already introduced here. Once the user logs in a callback function is called:

const loginUser = async () => {
    const loginResponse = await msalInstance.loginPopup({ scopes:[] });
    props.login(loginResponse.account?.name!, loginResponse.account?.username!, loginResponse.idToken);
};

The callback function now retrieves the userID, userName and the idToken. Having that the fluidContainer can be instantiated:

const login = async (name: string, userName: string, idToken: string) => {
    setUserDisplayName(name);    
    setUserLoggedin(true);
    setUserID(userName);
    const container: IFluidContainer = await getFluidContainer(userName, idToken);
    setFluidContainer(container);
};

Here only a valid user authentication was considered. Of course you can furthermore use Azure AD security group based authorization which I showed client-side here or server-side here.

Azure Fluid Relay management

Of course many containers might get created over time And there might be a need for a proper lifecycle management. Here are some methods with Microsoft Azure CLI for that. The first one is for listing all available containers:

az rest --method get --uri https://management.azure.com/subscriptions/<subscriptionId>/resourcegroups/<resourceGroupName>/providers/Microsoft.FluidRelay/FluidRelayServers/<frsResourceName>/FluidRelayContainers?api-version=<apiVersion>

This might result in an output like this:

[
...
{
      "id": "/subscriptions/*9ba8c0*-****-****-****-**c081cd91**/resourceGroups/DefaultMMsharepoint/providers/Microsoft.FluidRelay/fluidRelayServers/mmdemorelay/fluidRelayContainers/72bb13b9-55e6-4007-b11c-0607c3f94f38",
      "name": "72bb13b9-55e6-4007-b11c-0607c3f94f38",
      "properties": {
        "frsContainerId": "72bb13b9-55e6-4007-b11c-0607c3f94f38",
        "frsTenantId": "34d381d*-****-****-****-*******60f90"
      },
      "resourceGroup": "DefaultMMsharepoint",
      "type": "Microsoft.FluidRelay/fluidRelayServers/fluidRelayContainers"
    }
  ]

That one now can be deleted quite similar to the get command:

az rest --method delete --uri https://management.azure.com/subscriptions/9ba8c0*-****-****-****-**c081cd91**/resourcegroups/DefaultMMsharepoint/providers/Microsoft.FluidRelay/FluidRelayServers/mmDemoRelay/FluidRelayContainers/72bb13b9-55e6-4007-b11c-0607c3f94f3?api-version=2021-08-30-preview

For further reference refer to the Microsoft documentation.

Summary and outlook

The application now looks only slightly different than in the first part. The main difference is, that it only establishes a container after a successful user login. This also works with different users of course. But the rest, especially the real-time data synchronization finally stays the same:

The app before a user login
The app after a user login
The app in action – with different users but still syncing data

While in the first part the client site usage of Microsoft FluidFramework was shown, in this part a more professional storage option with Microsoft Azure Fluid Relay service was described in detail. Especially the necessity for secure authentication was explained with several facets. In my opinion this could be further improved out-of-the-box by Microsoft Azure with serverless components. Currently too much coding still necessary. But remember that we are talking about public preview technology at the time of writing this post.

Next we are going to try to use both described parts, client-side and server-side storage option inside a Microsoft Teams application. Finally for your reference you can meanwhile find both of my code repositories in my GitHub:

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
FluidFramework in a collaborative app (Vote Movies)

FluidFramework in a collaborative app (Vote Movies)

In my last post I showed a teams meeting app where it was a possible to vote for movies. I also showed the challenge you have in such collaborative apps: To sync interactive data collected from the user over several clients.

In this post I want to introduce Microsoft’s Fluid Framework as a solution for that. As a starter let’s concentrate on the very basics using the fluid framework in a standalone application for demo purposes. Later we will introduce enterprise resources and also the integration into Microsoft Teams.

Series

Content

Client and container

The data to be synchronized needs to be part of a container. The container would be synchronized  server-side and therefore a client is needed to access it.

There should be a container per app instance represented by an id. This id is added as a hash to the url here. In Teams later, where an instance represents an installed channel tab or an app installed to a specific meeting, this is handled slightly different on app installation. But here in this stand alone demo it is that easy.

In this little demo app we will only use a small and lightweight tinylicious component. More enterprise ready resources such as Azure Fluid Relay will be covered in one of my next posts.

In Code this looks like the following:

import { TinyliciousClient } from '@fluidframework/tinylicious-client';
import { IFluidContainer, SharedMap } from 'fluid-framework';
import { FluidVoting } from './components/FluidVoting';
const client = new TinyliciousClient();
const containerSchema = {
  initialObjects: { sharedVotes: SharedMap }
};
const createContainer = async () => {
  const { container } = await client.createContainer(containerSchema);
  const containerId = await container.attach();
  // Initialize votes
  const sharedVotes = container.initialObjects.sharedVotes as SharedMap;
  sharedVotes.set("votes1", 0);
  sharedVotes.set("votes2", 0);
  sharedVotes.set("votes3", 0);
  return containerId;
};
const getContainer = async (containerId: string) => {
  const { container } = await client.getContainer(containerId, containerSchema);
  return container;
}
const getFluidContainer = async () => {
  let containerId: string = window.location.hash.substring(1);
  if (!containerId) {
    containerId = await createContainer();
    window.location.hash = containerId;
  }
  const container = await getContainer(containerId);
  return container;
};
const App = () => {
  const [fluidContainer, setFluidContainer] = React.useState<IFluidContainer>();
  const [fluidContainerMap, setFluidContainerMap] = React.useState<SharedMap>();
  
  React.useEffect(() => {
    getFluidContainer()
     .then(c => setFluidContainer(c));
  }, []);
  React.useEffect(() => {
    if (fluidContainer !== undefined) {
      const sharedVotes = fluidContainer.initialObjects.sharedVotes as SharedMap;
      setFluidContainerMap(sharedVotes);
    }
  }, [fluidContainer]);
  if (fluidContainerMap !== undefined) {
    return (
      <FluidVoting votingMap={fluidContainerMap!} />
    );
  }
  else {
    return (
      <div >Loading votings...</div>
    );
  }
};
export default App;

If there is already an app instance or not is detected by the location hash value. If there is access to the container is directly established if not the container gets created.

Fluid component

The UI component dealing with the shared values is separated. As properties it receives the shared map from the parent component.

The shared map provides the given properties for the instantiation of the react state variables. Then we have a video elements and the corresponding vote buttons. On the vote buttons there is an increment method. Once a button is clicked the number of votes is incremented by one and then the synchronization process takes place.

The synchronization process from button click (Vote) till sync in any other client will work with the following steps:

  1. .set the new value to the shared map (line 28, 32, 36)
  2. Fluid Framework will automatically sync this server
  3. “ValueChanged” event is fired at any client that subscribed to (line 9)
  4. Custom event receiver should update React state variable (line 10-14)

In code this looks like the following:

export const FluidVoting = (props: IFluidVotingProps) => {
  const [votes1, setVotes1] = React.useState<number>(props.votingMap.get("votes1")!);
  const [votes2, setVotes2] = React.useState<number>(props.votingMap.get("votes2")!);
  const [votes3, setVotes3] = React.useState<number>(props.votingMap.get("votes3")!);
  const videoSrc1 = `https://ia904503.us.archive.org/5/items/windows-7-sample-video/Wildlife.mp4`;
  const videoSrc2 = `https://adaptivecardsblob.blob.core.windows.net/assets/AdaptiveCardsOverviewVideo.mp4`;
  const videoSrc3 = `https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/Sample-MP4-Video-File-for-Testing.mp4`;
  React.useEffect(() => {
    const updateVotes = () => {
      setVotes1(props.votingMap.get("votes1")!);
      setVotes2(props.votingMap.get("votes2")!);
      setVotes3(props.votingMap.get("votes3")!);
    };
    props.votingMap.on("valueChanged", updateVotes);
    return () => {
      props.votingMap.off("valueChanged", updateVotes);
    };
  });
    return (
        <div className='appContainer' >
          <div className="videoFrame">
            <video src={videoSrc1} controls width={260}></video>
          </div>
          <button onClick={() => { props.votingMap.set("votes1", votes1! + 1); }}>Vote Movie 1</button>
          <div className="videoFrame">
            <video src={videoSrc2} controls width={260}></video>
          </div>
          <button onClick={() => { props.votingMap.set("votes2", votes2! + 1); }}>Vote Movie 2</button>
          <div className="videoFrame">
            <video src={videoSrc3} controls width={260}></video>
          </div>
          <button onClick={() => { props.votingMap.set("votes3", votes3! + 1); }}>Vote Movie 3</button>
          <div>
              <span className="votesResult"><text>{`Votes Movie 1: ${votes1}`} </text></span>
              <span className="votesResult"><text>{`Votes Movie 2: ${votes2}`} </text></span>
              <span className="votesResult"><text>{`Votes Movie 3: ${votes3}`} </text></span>
          </div>
        </div>
      );
};

Testing

To run the app it’s not only necessary to start the app itself. Additionally the lightweight tinylicious server-side component needs to be started. So it’s necessary to fire up two consoles. In the first start the tinylicious server:

npx tinylicious

In the second start the app itself:

npm run start

Now after start the application is also opened in default browser and the url switches after a short moment from http://localhost:3000 to something like http://localhost:3000/#ae170b55-b402-4574-bbd5-a63abdcb1642

When you copy that url and open it in a different browser (tab) you can see the same votes and see them syncing while clicking the Vote buttons in the 1st or 2nd browser (tab). If you open the app again without the hash in the url (http://localhost:3000) you will see fresh values, not syncing and a new hash attached to the url, that is a new app instance with a new fluid container.

One app instance syncing in two browser clients look like the following picture:

Fluid Framework in action – Two browsers in-sync

As always the whole app’s source code can be found in my GitHub repository. In a follow up post I might show you how to replace the more demo and dev environment targeted tinylicious component by Azure Fluid Relay which is in Public Preview currently. So stay tuned.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.