Messaging
Messaging workspace
https://fve.meiermade.com/components/page-examples/messaging?state=ready
Gather
Workspace
Northwind Outdoor
Messages
Your conversations, together in one place.
Beach weekend
Andy, Jordan, Kella
Thursday, September 17
Jordan · 09:12
Are we still thinking Saturday for the coast? The weather looks good.
Andy Meier · 09:14
Yes! Let's leave after breakfast. I'll bring the picnic.
Kella · 09:18
I found a place near the beach. There's parking and a short trail down to the water.
Andy Meier · 09:20
That sounds perfect. Could you send us the meeting point?
open System
open FSharp.ViewEngine
open Acme.Components.Primitives
open Acme.Components.Application
open Acme.Components.Documentation
open type Html
open type Svg
open type Datastar
type ExamplePage =
| DependencyGraph
| ExecutionDetail
| FinancialReporting
| Messaging
| Operations
| Scheduling
| MediaManagement
type ReviewState =
| Setup
| Ready
| Loading
| Empty
| Failed
type ExampleQuery =
{ state: ReviewState
item: string
view: string
range: string }
let defaultQuery =
{ state = Ready
item = ""
view = ""
range = "" }
let slug =
function
| DependencyGraph -> "dependency-graph"
| ExecutionDetail -> "execution-detail"
| FinancialReporting -> "financial-reporting"
| Messaging -> "messaging"
| Operations -> "operations-dashboard"
| Scheduling -> "scheduling"
| MediaManagement -> "media-management"
let title =
function
| DependencyGraph -> "Dependency graph"
| ExecutionDetail -> "Execution detail"
| FinancialReporting -> "Financial reporting"
| Messaging -> "Messaging"
| Operations -> "Operations dashboard"
| Scheduling -> "Scheduling"
| MediaManagement -> "Media management"
let url page =
"/components/page-examples/" + slug page
let stateKey =
function
| Setup -> "setup"
| Ready -> "ready"
| Loading -> "loading"
| Empty -> "empty"
| Failed -> "error"
let queryUrl page query =
let pairs =
[ "state", stateKey query.state
"item", query.item
"view", query.view
"range", query.range ]
url page
+ "?"
+ (pairs
|> List.filter (snd >> String.IsNullOrEmpty >> not)
|> List.map (fun (key, value) -> key + "=" + Uri.EscapeDataString value)
|> String.concat "&")
let link (href: string) (label: string) : HtmlElement =
a {
_href href
_class
"font-medium text-[var(--fve-brand-text)] underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-[var(--fve-brand-ring)]"
label
}
let section (heading: string) (content: HtmlElement) =
Section.create (SectionHeader.create heading |> SectionHeader.withDivider) content
|> Section.render id
let details (fields: (string * string) list) =
DescriptionList.create [ for label, value in fields -> DetailField.text label value ]
|> DescriptionList.withColumns DescriptionListColumns.Three
|> DescriptionList.render
let reviewStates page =
(if page = Operations then [ Setup, "Setup" ] else [])
@ [ Ready, "Populated"; Loading, "Loading"; Empty, "Empty"; Failed, "Error" ]
let stateContent page query content =
match query.state with
| Setup
| Ready -> content
| Loading ->
div {
_class "p-8"
LoadingIndicator.create ("Loading " + (title page).ToLowerInvariant())
|> LoadingIndicator.withVisibleLabel
|> LoadingIndicator.render
}
| Empty ->
EmptyState.create "Nothing to show yet" "Change the current selection or return to the populated workspace."
|> EmptyState.withActions (link (queryUrl page { query with state = Ready }) "Return to workspace")
|> EmptyState.render
| Failed ->
Notice.create
"workspace-error"
"This view could not be loaded"
(p { "Your selection is preserved. Try loading it again." })
|> Notice.withTone Tone.Critical
|> Notice.withActions (link (queryUrl page { query with state = Ready }) "Try again")
|> Notice.render
/// Place durable workspace identity above the scrollable destination list.
let sideNavWorkspace (name: string) =
div {
p {
_class "text-xs font-semibold uppercase tracking-wide text-[var(--fve-muted-text)]"
"Workspace"
}
p {
_class "mt-1 truncate text-sm font-semibold"
name
}
}
let workspace page heading subtitle canvas actions content =
let product, workspaceName, destinations =
match page with
| DependencyGraph
| ExecutionDetail ->
"Relay", "Customer analytics", [ DependencyGraph, "Dependencies"; ExecutionDetail, "Executions" ]
| FinancialReporting -> "Ledger", "Northwind Studio", [ FinancialReporting, "Overview" ]
| Messaging -> "Gather", "Northwind Outdoor", [ Messaging, "Messages" ]
| _ ->
"Fieldwork", "Northwind Outdoor", [ Operations, "Overview"; Scheduling, "Schedule"; MediaManagement, "Photos" ]
let items =
[ for destination, label in destinations -> SideNavItem.create destination label ]
let navigation =
SideNav.create
(slug page + "-nav")
(product + " navigation")
(SideNavHeader.create product)
[ SideNavSection.ungrouped items ]
|> SideNav.withCurrent page
|> SideNav.withWidth SideNavWidth.Narrow
|> SideNav.withContext (sideNavWorkspace workspaceName)
|> SideNav.withMobileContext (sideNavWorkspace workspaceName)
|> SideNav.withFooter (
div {
_class "flex min-w-0 items-center gap-3"
Avatar.create "Andy Meier" "AM" |> Avatar.render
p {
_class "truncate text-sm font-medium"
"Andy Meier"
}
}
)
let header =
PageHeader.create heading
|> PageHeader.withSubtitle subtitle
|> PageHeader.withActions (ActionCluster.create (slug page + "-actions") actions)
let topBar =
PageTopBar.create ()
|> PageTopBar.withContent (
div {
_class "flex min-h-[var(--fve-shell-bar-min-height)] items-center px-4 sm:px-6 lg:px-8"
Breadcrumbs.create
(slug page + "-breadcrumbs")
(product + " breadcrumb")
[ BreadcrumbItem.create page product; BreadcrumbItem.create page heading ]
|> Breadcrumbs.render url
}
)
let body =
Page.create header content
|> Page.withTopBar topBar
|> Page.withWidth PageWidth.Full
|> Page.withBodyLayout (
if canvas then
PageBodyLayout.Canvas
else
PageBodyLayout.Padded
)
|> Page.render id
AppShell.create (slug page + "-shell") navigation body
|> AppShell.withTheme (
ComponentsTheme.sky
|> ComponentsTheme.withDensity Density.Compact
|> ComponentsTheme.withControlSize ControlSize.Small)
|> AppShell.withBoundary AppShellBoundary.Container
|> AppShell.asPreview (product + " workspace")
|> AppShell.render url
type Conversation =
{ id: string
name: string
initials: string
participants: string
preview: string }
type Message =
{ id: string
conversation: string
author: string
body: string
time: string
outgoing: bool }
let conversations =
[ { id = "beach"
name = "Beach weekend"
initials = "BW"
participants = "Andy, Jordan, Kella"
preview = "I found a place near the beach." }
{ id = "studio"
name = "Studio team"
initials = "ST"
participants = "Andy, Maya"
preview = "The new photographs look great." }
{ id = "jordan"
name = "Jordan Lee"
initials = "JL"
participants = "Jordan Lee"
preview = "See you at 10:30 tomorrow." } ]
let initialMessages =
[ { id = "m1"
conversation = "beach"
author = "Jordan"
body = "Are we still thinking Saturday for the coast? The weather looks good."
time = "09:12"
outgoing = false }
{ id = "m2"
conversation = "beach"
author = "Andy Meier"
body = "Yes! Let's leave after breakfast. I'll bring the picnic."
time = "09:14"
outgoing = true }
{ id = "m3"
conversation = "beach"
author = "Kella"
body = "I found a place near the beach. There's parking and a short trail down to the water."
time = "09:18"
outgoing = false }
{ id = "m4"
conversation = "beach"
author = "Andy Meier"
body = "That sounds perfect. Could you send us the meeting point?"
time = "09:20"
outgoing = true }
{ id = "m5"
conversation = "studio"
author = "Maya"
body = "The new photographs look great. Can you send the final selection this afternoon?"
time = "08:45"
outgoing = false }
{ id = "m6"
conversation = "jordan"
author = "Jordan"
body = "See you at 10:30 tomorrow. I'll meet you by the entrance."
time = "Yesterday"
outgoing = false } ]
let private sentMessage =
{ id = "message-sent"
conversation = "beach"
author = "Andy Meier"
body = "Meet at the east entrance 15 minutes before we leave."
time = "Just now"
outgoing = true }
let currentConversation query =
conversations
|> List.tryFind (fun item -> item.id = query.item)
|> Option.defaultValue conversations.Head
let messagesFor query =
if query.view = "sent" then
initialMessages @ [ { sentMessage with conversation = (currentConversation query).id } ]
else
initialMessages
let messageHistory conversation messages =
div {
_id "message-history"
_tabindex 0
_role "log"
_ariaLabel (conversation.name + " message history")
_ariaLive "polite"
_class "min-h-0 flex-1 space-y-5 overflow-y-auto p-4 sm:p-6"
p {
_class "text-center text-xs text-[var(--fve-muted-text)]"
"Thursday, September 17"
}
for message in messages |> List.filter (fun message -> message.conversation = conversation.id) do
div {
_id ("message-" + message.id)
_dataInit "el.parentElement.scrollTop = el.parentElement.scrollHeight"
_class (
if message.outgoing then
"ml-auto grid max-w-[85%] justify-items-end gap-1"
else
"mr-auto grid max-w-[85%] gap-1"
)
p {
_class "text-xs text-[var(--fve-muted-text)]"
message.author + " · " + message.time
}
p {
_class (
if message.outgoing then
"whitespace-pre-wrap break-words rounded-2xl rounded-br-sm bg-[var(--fve-brand-solid)] px-4 py-3 text-base text-white"
else
"whitespace-pre-wrap break-words rounded-2xl rounded-bl-sm bg-[var(--fve-surface-subtle)] px-4 py-3 text-base text-[var(--fve-text)]"
)
message.body
}
}
}
let conversationPanel query messages draft error =
let conversation = currentConversation query
div {
_id "conversation-panel"
_class "flex h-full min-h-0 flex-col"
header {
_class "flex items-center gap-3 border-b border-[var(--fve-border)] p-4"
Avatar.create conversation.name conversation.initials |> Avatar.render
div {
h2 {
_class "text-base font-semibold"
conversation.name
}
p {
_class "text-xs text-[var(--fve-muted-text)]"
conversation.participants
}
}
}
messageHistory conversation messages
form {
_method "post"
_action (url Messaging + "/send?item=" + conversation.id)
_class "grid shrink-0 gap-3 border-t border-[var(--fve-border)] bg-[var(--fve-surface)] p-4"
let field =
Textarea.create "message" "Message"
|> Textarea.withId "message-compose"
|> Textarea.withRows 2
|> Textarea.withValue draft
|> Textarea.required
|> Textarea.withAttributes [ _maxlength 2000 ]
(match error with
| Some message -> field |> Textarea.withValidation message
| None -> field)
|> Textarea.render
div {
_class "flex flex-wrap items-center justify-between gap-3"
p {
_role "status"
_class "text-xs text-[var(--fve-muted-text)]"
if query.view = "sent" then
"Message accepted. This deterministic example does not retain submitted text."
else
"Only participants in this conversation can see your reply."
}
Button.create "Send message"
|> Button.withVariant ButtonVariant.Primary
|> Button.asSubmit
|> Button.render
}
}
}
let messaging query =
let current = currentConversation query
let messages = messagesFor query
let error =
match query.view with
| "message-empty" -> Some "Enter a message before sending."
| "message-too-long" -> Some "Keep your message under 2,000 characters."
| _ -> None
let destination id =
queryUrl Messaging { defaultQuery with item = id }
let navigation =
SideNav.create
"gather-conversations"
"Conversations"
(SideNavHeader.create "Gather")
[ SideNavSection.group
"Messages"
[ for conversation in conversations -> SideNavItem.create conversation.id conversation.name ] ]
|> SideNav.withCurrent current.id
|> SideNav.withWidth SideNavWidth.Wide
|> SideNav.withContext (sideNavWorkspace "Northwind Outdoor")
|> SideNav.withMobileContext (sideNavWorkspace "Northwind Outdoor")
|> SideNav.withFooter (
div {
_class "flex items-center gap-3"
Avatar.create "Andy Meier" "AM" |> Avatar.render
span {
_class "text-sm font-medium"
"Andy Meier"
}
}
)
let content =
Html.section {
_ariaLabel "Current conversation"
_class "h-full min-h-0"
conversationPanel query messages "" error
}
let page =
Page.create
(PageHeader.create "Messages"
|> PageHeader.withSubtitle "Your conversations, together in one place.")
(stateContent Messaging query content)
|> Page.withTopBar (
PageTopBar.create ()
|> PageTopBar.withContent (
div {
_class "flex min-h-[var(--fve-shell-bar-min-height)] items-center px-4 sm:px-6 lg:px-8"
Breadcrumbs.create
"gather-breadcrumbs"
"Gather breadcrumb"
[ BreadcrumbItem.create "beach" "Gather"
BreadcrumbItem.create current.id "Messages" ]
|> Breadcrumbs.render destination
}
)
)
|> Page.withBodyLayout PageBodyLayout.Canvas
|> Page.render destination
AppShell.create "messaging-shell" navigation page
|> AppShell.withTheme (
ComponentsTheme.sky
|> ComponentsTheme.withDensity Density.Compact
|> ComponentsTheme.withControlSize ControlSize.Small)
|> AppShell.withBoundary AppShellBoundary.Container
|> AppShell.asPreview "Gather workspace"
|> AppShell.render destination
div { _class "h-[44rem]"; messaging defaultQuery }
|> Browser.create
|> Browser.withAddress "https://fve.meiermade.com/components/page-examples/messaging?state=ready"
|> Fixture.browser "page-workspace" "Workspace" "/components/page-examples/messaging?state=ready"
|> Fixture.render