Anahita Web
Architecture
How the app is put together, and how it talks to anahita-services.
Directory layout
Everything is under src/:
| Directory | What it holds |
|---|---|
api/ |
One module per API resource. Each returns axios promises; index.js sets up axios and exports them all |
actions/ |
Redux thunks that call the API and dispatch the result |
reducers/ |
Redux reducers, most of them made by shared factories |
store/ |
The Redux store, with a development and a production version |
containers/ |
Pages and components connected to the store, grouped by feature (actors, media, replies, settings, auth…) |
components/ |
Presentational components with no store access |
routes/ |
The route table and the four route guards |
permissions/ |
What the viewer may do, mostly read from the server's answers |
languages/ |
Translations, one directory per language |
assets/ |
Themes: logo, home page (what somebody not signed in sees at /), left menu, styles and static pages |
statics/ |
Files shipped with the app, including the Terms of Service and Privacy Policy |
proptypes/ |
PropTypes shapes and matching default objects for the API's entities |
styles/ |
Builds the Material UI theme from the active theme's styles |
constants/, utils/, middleware/ |
Shared helpers |
Talking to the API
All requests go through axios, configured in src/api/index.js:
- Base URL. Every request is relative to
REACT_APP_API_BASE_URL. - Cookies. Requests are sent with credentials, because the session is a cookie set by the API's domain.
- Key casing. The API speaks
snake_caseand the appcamelCase. A request interceptor converts outgoing keys, including form-data field names, tosnake_case. A response interceptor converts incoming keys tocamelCase. Code in the app never sees asnake_casekey. - Redirects are not followed (
maxRedirects: 0).
What each route takes and sends is in the services' API reference
(anahita-services docs/api.md and docs/api/, made from the code); to read
it, open anahita-services docs/api/index.html in a browser. It describes the
wire, in snake_case, not the camelCase the app sees.
A few documents come from elsewhere and skip the interceptors. For example, the
OpenID configuration goes through api/publicClient.js, which repeats the key
conversion itself.
Signing in
The app never handles a password. Signing in is OAuth 2.0 authorization code
with PKCE, as the first-party client anahita-web (src/api/session.js):
- Sign in. The app generates a random state and PKCE verifier, keeps them
in
sessionStorage, and sends the browser to the server's/oauth/authorize. - Callback. auth-service shows its own sign-in page, then redirects back
to
/oauth/callbackwith a code (containers/OAuthCallback.jsx). - Session. The app posts the code and verifier to the server's
/oauth/session. The server exchanges them for tokens, keeps the tokens itself, and sets a session cookie. From then on, requests carry the cookie and the gateway attaches the access token on the way in. - Viewer. The app reads the signed-in person from
/oauth/userinfo.
Signing up, accepting an invitation and confirming an email address are pages on auth-service too. The app only links to them.
Four components guard routes (src/routes/):
AuthenticatedRoutesends signed-out visitors to/auth. It waits for the session check to finish first, so a signed-in person is not bounced on every reload.AgreementsGatewraps the whole route table. It sends a signed-in person to/agreementswhen the Terms of Service or Privacy Policy has a newer version than the one they accepted; after that, to/ruleswhen the site's rules have (roadmap row 102). The rules are the server's, read once fromGET /site(containers/site/useSite.js,useRules, with its own client: no credentials, and language names left as they are). On their page (containers/rules) each is ticked in turn: the next zooms forward a moment after the one before is ticked, the keyboard following; for anybody not behind on them it is a numbered list to read. Withmetadata.siteRulesfalse, nobody is sent: whoever installs Anahita makes its creator's pledge on auth-service's set-password page instead.OnboardingGatewaits for both. The About page shows the site's promises first, from the sameGET /site, with the way to/rules(containers/about/Promises.jsx). An administrator removes a person or a group as spam, at once, from its menu or from a report's case (containers/moderation/useRemoveAsSpam.jsx, asked once more). A group's and an event's rules are written in a Rules tab of its settings (actors/Settings/Rules.jsx: at most ten, in order, picked from the site's suggestions or written; the site's own open in a dialog from there,rules/SiteRulesDialog.jsx) and listed in its About card (actors/Read/useRules.js). Every list of rules, here, on/rules, in the checklists and on the server's sign-up pages, is numbered when it has more than three (utils/lists.js). Following one, asking to, answering an event or accepting an invitation first asks for its rules, ticked one at a time in a dialog (containers/rules/useRulesFirst.jsx,components/RulesChecklist.jsx, which/rulesuses too), unless the viewer agreed to the version they are at; the server refuses otherwise (409rules_not_agreed). A new post on one asks the same when its rules changed since (both composers, withuseRulesFirst(actor, 'post')). See Terms of Service and Privacy Policy.OnboardingGate, inside it, sends a signed-in person with an incomplete profile to/onboarding, once, and waits while the agreements are outstanding so the two never send somebody back and forth.MembersOnlyGate, on a members-only site, shows somebody who is not signed in a way in instead of the page they asked for. See A members-only site.
All four are gates on the interface. What may be read is decided by the server.
State
State is kept in Redux, with redux-thunk for asynchronous actions.
Most resources behave the same way, so their actions and reducers are made by factories instead of being written out each time:
actions/create.jsmakesbrowse,read,edit,addanddeleteItemactions for a namespace, such aspeople,groups,notesorphotos.reducers/create.jsmakes the matching reducer. It keeps each namespace's items by id, their order, thecurrentitem being viewed, and loading and error state.
actions/index.js and reducers/index.js list the namespaces and wire them
up. Anything that does not fit the pattern, such as the session or the
socialgraph, has its own module.
In development the store also logs every action, and connects to the Redux DevTools browser extension when it is installed.
Routes
The route table is src/routes/index.js, using React Router 7 with browser
history. The main paths:
| Path | Page |
|---|---|
/ |
The landing page when signed out. Signed in, home: containers/feeds, with the feed and the + button to post. /dashboard leads here |
/auth, /oauth/callback |
Signing in |
/@:alias, /@:alias/:tab |
A profile of any kind at its alias, as it is known on the fediverse: a person (/@rastin), somebody on another server by their whole handle (/@alice@mastodon.example, /@alice.bsky.social), a group (/@anahita-atrium) or an event. containers/actors/ByHandle.jsx asks the API which profile the alias is (api.handles, GET /actors/:alias) and draws its page, its tabs under /@:alias. utils/node.getURL gives every profile this address; getKindURL gives the one by kind, under which settings are |
/people, /people/:id |
People. A person's old address, /people/:id and its tabs, sends to /@:id |
/people/:id/:tab, /groups/:id/:tab |
A group's page opened on one of its tabs at its address by kind, which still works; for a person, sent to /@:id/:tab |
/saved |
The posts the viewer saved. Signed in only; in the left menu |
/recommendations |
People, groups and events the viewer might follow, and feeds people have shared, a tab each (?kind=). Signed in only; reached from Who to follow on Home, and from the left menu where the installation has the recommendation service. /suggestions, its first address, leads to it |
/feeds, /feeds/new, /feeds/:id/edit |
The viewer's custom feeds, making one, changing one. Signed in only |
/feeds/:id |
A custom feed's own page. Read without signing in when the feed is public |
/events, /events/add, /events/:id |
The viewer's own events, making one, an event's page. /events/:id/edit sends to the event's settings. See Events |
/people/add, /groups/add |
Making a person (administrators) or a group |
/people/:id/settings/password |
The old address of the password form, linked from older emails. Sends to Security |
/people/:id/settings/:section |
A person's settings, grouped into sections |
/groups, /groups/:id |
Groups, and a group. :id is <id>-<slug> |
/groups/:id/settings |
A group's settings |
/websites, /websites/:id, /websites/:id/settings |
Websites, a website (a site's feed as a profile), and its settings: Info, Admins, Blocked, On your site, Danger zone. Its page is at /@alias like any profile |
/websites/connect |
Connecting a website: its address, the proof of its domain, then its name and handle. For those the installation's websitesFrom allows. See Websites in anahita-services |
/notes/:id, /articles/:id, /topics/:id, /photos/:id, /videos/:id |
One post. There is no page listing every post of a type; the bare paths send an old bookmark home |
/hashtags, /hashtags/:alias, /locations, /locations/:id |
Hashtags and places, the lists and each one. The old /tags/:alias sends to /hashtags/:alias |
/notifications |
The viewer's notifications |
/admin/:tab |
The administration area. The tab is in the address so an email can link to it. See below |
/invites |
Invitations, for a member who may invite. An administrator is sent to /admin/invites |
/settings |
Site settings, for super administrators only. A page and a menu entry of its own: OAuth clients, signing keys, Privacy, which shows what the installation lets visitors read and can make everything public members-only, and Fediverse, the servers the site refuses |
/signup-requests |
Where the signup queue used to be. Redirects to /admin/signup-requests |
/legal/tos, /legal/privacy, /agreements |
The legal documents, and accepting new versions |
/onboarding |
Where somebody signed in with an incomplete profile is sent, once (routes/OnboardingGate.jsx): a name and a few words; a picture, in a circle that shows the initials of that name until there is one; hashtags they are interested in, up to ten, which make one "My interests" feed on their home page; profiles the site features; then people and groups recommended for them, with the reason for each. Nothing is followed without a tick. A step with nothing to show is left out (onboarding/featured.js#stepsFor), every step can be skipped, and without the recommendation service there are no interests and no recommendations (containers/onboarding) |
/pages/:alias |
A page of the installation's own, from the theme's pages/ folder (src/assets/<theme>/pages). /pages/tos and /pages/privacy redirect to /legal |
/404, and any address not above |
The not-found page |
/search, /blogs, /support, /about |
Everything else |
A members-only site
Everything about who can see what, on the server and here, is in one place: Privacy in anahita-services. What follows is the app's part of it.
An installation can serve nothing to people who are not signed in
(SITE_READ_ACCESS=registered on the server). The server enforces it: every
content route answers 401 to them. NodeInfo still answers and says so in
metadata.readAccess, and routes/MembersOnlyGate.jsx, which wraps the whole
route table, reads that. Somebody who is not signed in then sees a notice with
a Sign in button and the home page, in place of whatever page they asked for
and at the same address, so a link to a post leads to the post once they are
in. Every page carries robots: noindex.
The pages that stay open, the ways in and the pages about the site, are listed
in routes/membersOnly.js.
The setting has a middle value, preview: visitors are sent only the start of
what is public, cut on the server. The pages work, so the same gate leaves
them in place under a notice that this is a preview. A post that was cut
arrives with truncated set, and components/SignInPrompt.jsx shows the way
to the rest under it. Lists stop at their first page by themselves: the server
lowers their total to what it sent.
The app does not ask for what a visitor will be refused. utils/visitor.js
answers, from the session and NodeInfo, whether the viewer is a visitor and on
which kind of site:
- On a preview site the replies under a post and the lists of who follows whom are not requested. A sign-in prompt stands where they would be.
- On a members-only site the search box and the People, Groups, Hashtags and Places menu entries are not shown.
A page that could be refused waits to be drawn until the session has been
read and NodeInfo has answered. Drawn sooner, it asks for its content at once,
is refused, and shows the refusal before the gate has caught up. The wait is a
moment, and an open site does not pay it: what the site said last time is kept
in the browser (site.readAccess in localStorage), and a site that said
public is drawn straight away.
A request that is refused anyway, because some page did not expect it, is
caught in src/index.js: an unhandled "sign in first" answer from the server
is dropped instead of reaching the screen as an error. Every other unhandled
failure is left to be seen.
The administration area
/admin is one page with a tab per thing administrators look after
(src/containers/admin). Each tab names who may see it in
containers/admin/tabs.js, reusing the rule the page behind it enforces:
| Tab | Address | Who sees it |
|---|---|---|
| Reports | /admin/reports, and /admin/reports/:id for one case |
Administrators and super administrators |
| Signup requests | /admin/signup-requests |
Administrators and super administrators |
| Invites | /admin/invites |
Administrators and super administrators |
| Scheduled for deletion | /admin/scheduled |
Super administrators only |
| Accounts | /admin/accounts |
Super administrators only |
Scheduled for deletion (containers/admin/Scheduled) lists every profile
on its way out, people, groups, events and websites together, soonest first,
each with its days left and a Settings button to restore it or remove it for
good. It asks GET /accounts for the deleted of each kind.
Site settings are not a tab. They configure the installation, where this area
looks after the people on it, so they keep their own page (/settings) and
menu entry.
Accounts (containers/admin/Accounts) lists every person, group, event or
website, least recently active first, with filters for finding the ones nobody
uses: an activity tier (empty, dormant, active), a state (active, disabled,
archived, deleted), dates, never verified, never onboarded, nothing written,
and for groups, events and websites, no administrator. A deleted group, event
or website's Deleted chip leads to its settings, to restore it or remove it
for good. Accounts are ticked, or all
that match are selected at once, up to 500 (people and groups; events and
websites are removed one at a time, from their settings), and "Delete permanently" opens a
dialog that says what will go, asks for the count to be typed (PURGE 37) and
for a proof of identity, then shows the progress. The server answers a purge
before the work is done, so usePurgeProgress asks how the batch is doing
until nothing is queued. Administrators and the viewer cannot be ticked. A
group with no administrator links to its settings, where one is appointed.
A profile scheduled for deletion, of any kind, carries a Scheduled for
deletion · N days left label (components/ScheduledDeletion.jsx,
utils/deletion.js) on its page and in the lists of who somebody follows,
which keep it for its followers and administrators only (graph-grpc-service,
setLiveNeighboursQuery). Its Danger zone offers only Restore, for a group,
an event or a website, and Delete permanently.
The same purge for one account is the last card in a profile's Danger zone,
"Delete permanently" (containers/actors/Settings/Purge.jsx), offered to
super administrators on anybody's profile but their own. What a purge removes
is in the services' docs/account-lifecycle.md.
A person's account type (registered, administrator or super
administrator) is changed on the Account type card, the first in the
Administration section of their settings (people/Settings/AccountType.jsx),
offered to administrators on anybody's settings but their own. Its dialog
(AccountTypeDialog.jsx) offers only what the viewer may make
(permissions.account.personTypesFor, mirroring MayChangePersonType in
anahita-libs): an administrator makes administrators of registered people,
and making a super administrator or taking an account type away is a super
administrator's. It is saved with PATCH /accounts/:id/person-type after a
proof of identity (useStepUp). Nobody is offered their own, which the server
refuses too: that is what keeps the last super administrator. The change takes
effect within fifteen minutes, when the person's sign-in is next renewed.
A tab the viewer may not see is not drawn, and its address lands on the first tab they may. Somebody with no tabs sees a "restricted" message, and has no Administration entry in the left menu.
The menu entry carries the number of things waiting: open reports plus pending
signup requests. The app reads it when an administrator's session is known,
every five minutes, when the window regains focus, and after a request is
approved or rejected (actions.admin.readCounts, state.admin.counts).
Reporting
Every menu that sits on a node has a Report item: profiles, posts, feed
items, replies, hashtags and places. It is offered to anybody signed in,
except on themselves and on what they wrote (permissions/report.js). A menu
that somebody could do nothing else in is now drawn for them, holding Report.
The item comes from the useReport(viewer, node) hook in
containers/reports, which also returns the dialog. The dialog is rendered
beside the menu, not inside it, because choosing the item closes the menu.
The reasons it lists come from the server, for the kind of thing being
reported and in the app's language, and are fetched once per kind.
First among them is Breaks a rule, when there are rules to break:
choosing it lists the site's rules (GET /site, unless SITE_RULES is
off) and those of the group or event the thing is in, or is. The rule's
title and whose it is go with the report (rule, rule_profile_id, 0 for
the site's). A case names the rule and whose it is, and "Breaks a rule" is
one of the reasons to filter by.
A group's or an event's administrators find the reports against its rules
in the Reports tab of its settings (actors/Settings/Reports.jsx,
GET /abuse-reports/profiles/:id/cases): what was reported, the rule and
the reporter's words, never who reported, and never a report on the group
itself or on what the viewer wrote. View goes to the post, where
deleting it closes its report.
The Reports tab lists cases, filtered by status and by reason with two select
lists, and adds more with a "Show more" button. It opens one case at a time. It records an
administrator's decision; it does not delete or disable anything. "View"
goes to the reported thing, where those controls already live. See
anahita-services' docs/abuse-reports.md.
What a viewer may do
The server decides, and the app follows. Every person, group, post and
reply the API returns carries an authorized object answering what the
viewer may do with it. The server works each answer out with the same checks it
enforces:
| Field | On | Answers |
|---|---|---|
edit, delete, administration |
Everything | Whether the viewer may edit, delete or administer it |
composers |
People and groups | Which kinds of post the viewer may create on the profile |
audiences |
People and groups | Who a post the viewer writes there may be shown to |
addFollower |
Groups | Whether the viewer may add somebody else as a follower |
comment |
Posts and replies | Whether the viewer may reply. The name is from when replies were comments |
like |
Posts and replies | Whether the viewer may like it |
The helpers in src/permissions/ read these answers. Where a response does not
carry an answer, the helpers fall back to a simple rule, and the server still
refuses anything it must. The app never works out a permission rule itself:
the rules depend on the profile's access, the viewer's relationship to it and
the profile's own settings, and a second copy of them in the browser has
drifted before.
The rules themselves, and how to change their defaults, are documented in anahita-services under Permissions.
Choosing who can see a post
One button, components/AudienceButton.jsx, in the two places the question
comes up: beside Post in the composer
(containers/media/Composer/Audience.jsx), and on a post that exists, for
somebody who may change who sees it (containers/controls/medium/Access.jsx).
A post carries its own authorized.audiences for that.
In the composer, it names the current choice and
opens a short menu; the choice is sent with the post as access, so a post is
never public first and narrowed afterwards.
What it offers comes from the server: every person and group carries
authorized.audiences, the audiences a new post there may have (the services'
docs/permissions.md, Choosing who can see a post). src/utils/audience.js
reads that and adds what the server has no opinion on:
- The options depend on where the post is going: your own profile, somebody else's, or a group.
- An audience wider than the profile itself is shown disabled, with the reason: a post is never seen more widely than its profile.
- It starts on the last audience used for that kind of place, remembered per
person in the browser's
localStorage, or else on the widest the profile allows. It works the same with storage unavailable, only without a memory.
The language of a post
Beside the audience button the composer has a language button
(components/LanguageButton.jsx), showing a code such as EN. The choice is
sent with the post as language. src/utils/postLanguage.js decides where it
starts: the posting language on their profile, which the session's viewer
carries; then the browser's language, which is the computer's; then
English. A different language chosen for one post applies to that post only.
It once remembered the language somebody last posted in, which made one post
sent in the wrong language the start of every post after it; it no longer
does, and clears what it kept. Language names come from the browser
(Intl.DisplayNames), in the language the app is being read in.
Where a post's text is drawn, components/NodeBody.jsx puts the post's
language on it as the lang attribute, so a screen reader reads each post in
the right one.
Right-to-left writing. The interface is left to right and has no
right-to-left layout. What people write is another matter: a post's title and
text, and the fields they are typed into, carry dir="auto", so Persian,
Arabic or Hebrew runs right to left and aligns to the right. The direction
comes from the text, not from the language tag, so it holds for untagged and
older posts, and each paragraph of a post is judged on its own. While a
post is written the box follows the language chosen for it
(postLanguage.directionOf): right to left for Persian, Arabic, Hebrew,
Urdu and the others, so an empty box starts at the right, and changing the
language changes the direction at once; otherwise from the first letter
typed. Replies and quotes have no language button and follow the
writer's own language. A placeholder takes its direction from its own words
(unicode-bidi: plaintext, in styles/index.js), so an English one reads
rightly in a box set right to left. Mirroring the
whole interface (MUI's direction: 'rtl' theme with the stylis RTL plugin)
would come with a Persian or Arabic translation of it, and there is none yet.
"Language you post in" is in the profile form. It is the language somebody writes in, not the language of the interface.
What the server stores and accepts is in the services'
docs/post-language.md.
The photos of a post
A photo post holds up to twenty images, or fewer where the installation says
so (PHOTO_MAX_FILES). The number comes from the server
(metadata.photoMaxFiles in NodeInfo); the app does not carry it, but for
four when the server has not said (utils/photoFiles.js).
Making one. The photo composer (Composer/Forms/Photo.jsx) shows
components/PhotoFilesEditor.jsx: pick or drop several images, put them in
order with the arrows under each, remove one, and describe each with its
ALT button. Each image is uploaded the moment it is picked, in a request of
its own (api.photos.upload), so the Post button waits until all of them are
stored and the post itself is then a small JSON request naming the uploads.
Showing one. A post with more than one image is drawn by
components/PhotoSlides.jsx on the post card, in the feed and on the photo's
page. Material UI has no carousel, so this is one made for this one job from
its small parts (ButtonBase, IconButton, MobileStepper for the dots)
with no other library. The photos sit side by side in a row that the browser
scrolls sideways and brings to rest on one photo (CSS scroll snap), so on a
phone a photo follows the finger and carries on with the flick. Where there
is a mouse there are arrows, and the left and right keys work on the focused
post. Photos after the first load when they are about to come into view. The
frame takes the first photo's shape, kept between 4 by 5 and about 2 by 1,
and a photo of another shape is shown whole inside it.
The lightbox shows a post's images as the same kind of row, swiped the same
way. It goes through a post's images before it goes on to the next post: the
arrows and the arrow keys step through them, and a swipe leaves the post only
from its first or last image. Stepper/index.jsx holds which image is on
show; the row tells it when a swipe has moved. Zooming takes the image on
show out of the row and shows it at full size, as for any photo. A post with
one image is drawn as it always was.
Every image carries its description as alt. One without a description falls
back to the post's title.
Changing one. "Edit photos" in a photo's menu opens
containers/media/PhotoFilesDialog.jsx, the same editor over what the post
has. Save sends the whole list as it is to be; nothing changes before that.
The rules that can be tested without a browser are in
src/utils/photoFiles.js: what can be sent, how a list is reordered, what a
step lands on. asSingle(medium, index) gives the post as if one of its
images were its only one, which is how everything that already draws a
photo from portraitUrls draws any of them.
In preview mode a visitor is sent one small image of a post however many it has, so it is drawn as a single photo.
What the server stores and accepts is in the services' docs/photos.md.
Replies
A post of any kind is answered with replies, each of which can be answered
in turn. A reply is a post of any kind too: words alone make a note, pictures
a photo post, a GIF or a video a video post. containers/replies/Thread.jsx shows them, as the Replies
tab on a post's page and in the lightbox. What used to be comments are
replies, made directly to the post.
-
The thread is read whole (
api.replies.thread) and kept in the component, not in the store. Nothing else on the page shows it. -
src/utils/thread.jsarranges the flat list the server sends into replies and the replies under them, and holds the rule for each change: a reply added, edited, removed, hidden. After a change is made on the server the same change is made to the copy on the page, so the thread is not read again after every reply. It is plain functions with tests. -
What a viewer may do comes with each reply, in
authorized:comment(reply to it),edit,delete,hide,like. A control is drawn only when its answer is yes. Whether somebody may reply to the post at all comes with the thread, ascanReply. -
A removed reply that had been answered arrives as
deleted, with no text and no author, and is drawn as a line saying so with its replies still under it. -
Hidden replies are sent only to whoever may show them again, marked
hidden. They are drawn apart, under the thread, closed until opened. The server also leaves out, for every reader, the replies of somebody the post's owner or writer has blocked. A reply from Bluesky is hidden there too, when the post went there; one from the fediverse is hidden here only, and asking to hide it says so, naming its server (utils/federation.js#fromFediverse). -
Replies are set in a level for each step down, up to four, with a line down the side. Past that they line up, so a long exchange fits a phone.
-
The reply box (
ReplyForm, withattachmentsfor a new reply) has a button for pictures and, where videos may be posted, one for a GIF or a video: one or the other, with words or without. Pictures use the photo composer's editor (PhotoFilesEditor, uploaded for the person replying) and go toapi.replies.addWithPictures. A GIF or a video is sent as a video is, with aparentId(VideoReplyOnTheWay, throughuseVideoUpload), shown as being sent and then prepared under the box, and the thread is read again once the reply is made. Nothing about who sees a reply, who may answer it or its language is asked: the thread's. -
A reply shows its pictures (
PhotoSlides) or its video (VideoPlayer, the looping clip for a GIF) under its words. Its words are changed at/notes/:idwhatever its kind; it is removed under its own kind's address (api.replies.remove), which takes its files with it. -
Who can reply is the post's own setting, chosen by its author: anyone, nobody, or any of the people who follow them, the people they follow and the people the post mentions.
components/ReplyAccessDialog.jsxasks it as two questions, anyone or nobody and then who is let in anyway, andutils/replyAccess.jsturns that into the one value the server keeps. The dialog opens fromReplyAccessButtonin the composer, beside the audience and the language, and from "Who can reply" in a post's menu (controls/medium/ReplyAccess.jsx). The thread reads the setting with the replies and says it when it is limited. -
A post's menu in a feed has Edit and Delete where the server says the viewer may (
authorized.edit,authorized.delete, sent with each feed item). Edit opens the post's page with its form up (?edit=1, which is then taken out of the address). A deleted post leaves every feed on the page, with any repost of it (reducers/createFeed.js). -
In a feed, the reply button under a post is a link to the post's page, where its thread is (
containers/feed/components/ReplyButton.jsx). How many replies it has is in its interactions. -
A reply has a page of its own, at
/notes/:idlike any note. It is drawn as the post, with what it answers above it (containers/replies/ReplyContext.jsx) and below it the part of the thread that is under it: the thread is read whole for the post at the top, andutils/thread.jspicks out the branch. Replying there answers that reply. -
Where a reply was said is shown above it wherever it is away from its thread: the profile the thread is on, a person, a group or any other actor, from
root.owner. A reply is owned by whoever wrote it, so its own owner does not say. A post shows the profile it is on the same way when that is not its author's own. -
A person's profile has four lists: Posts, Tagged, Replies and Reposts. Tagged is the photos and videos they are in, their tag approved, whoever posted them (roadmap row 103). Only people reply, repost and are tagged, so a group has Posts alone. They are the first tabs of
containers/actors/Read/Body.jsx. All four are the profile's feed asked for with a differentfilter(containers/feed/Actor); they share one place in the store, so each is keyed and read when its tab is opened. Posts are one column beside the profile's details; the others fill the page in the same masonry as the lists of notes, articles and photos (components/BreakpointMasonry.jsx). In Reposts the reposted post is drawn as itself, not inside a card for whoever reposted it. The tab is in the address. Reposts are not among the posts unless the profile's owner asks for that, under Settings › Privacy (containers/actors/Settings/RepostsOnProfile.jsx), and the server applies it.
Home
Home for somebody signed in is containers/feeds/index.jsx (it was
Dashboard.jsx; the menu says "Home" either way): the feed of the people and
groups the viewer follows, with the custom feeds the viewer keeps there as
tabs after it.
Posting is the + button at the bottom right
(containers/media/Composer/ComposeButton.jsx; roadmap row 99,
plans/done/compose-button.md (opens in a new tab)), on the home page and on
a profile the viewer may post to, and nowhere else. The page says where it
posts while it is open (reducers/composeTarget.js): from home to the
viewer's own profile, from a profile to that one. It opens the short
composer, "New post" (Composer/Short.jsx), whose Long content opens the
long one (Composer/Long.jsx); a profile that takes only long writing opens
that at once. A ring goes round the button while a video posted from it is
being prepared. What the viewer posts is at the head of an open feed it
belongs in at once (reducers/lastPosted.js, feed/usePosted.js): the
home feed, and the profile it was posted to.
Suggestions
Suggestions of who to follow come from the installation's recommendation
service (/recommendations/; the services' docs/recommendations.md). The
service is optional, and everything here draws nothing where there is none:
a request that fails as it does without the service
(utils/recommendations.js#isAbsent) is taken as there being no suggestions.
- Who to follow on the home page, above the feed
(
containers/recommendations/WhoToFollow.jsx): three people, each with the reason, + Follow and a ✕ for not interested, and a button on to the rest. As wide as the feeds: when they are read in columns, the three are side by side, each outlined like a card with its ✕ a small circle on the top left corner; otherwise a list. Not drawn while it is read or when there is nobody. The ✕ in its header puts the whole card away for seven days, remembered in the browser (utils/recommendations.js#hide); Reset on the Recommendations page brings it back. - The page at
/recommendations(containers/recommendations/index.jsx): People, Groups and Events, the open tab in the address (?kind=group), as cards with the reason inside each, under the name. An event is drawn with the event's own card and is not followed from here. It is in the left menu as Recommendations, where the installation has the service (useHasSuggestions.jsasks once). - Feeds, the last tab (
?kind=feed,containers/recommendations/SharedFeeds.jsx), is not chosen by the recommendation service: it is the feeds people have shared that the viewer may open, newest first (feed-service,GET /feeds/discover), without the viewer's own, each with the button that adds it to the home page. The weights and preferences beside the tabs do not change it. - What counts is the viewer's to weigh. The button beside the tabs, and
one in settings, opens a dialog of sliders
(
containers/recommendations/WeightsDialog.jsx), one a reason, from nothing, which leaves the reason out, to the most there is; Reset puts them back to the installation's own. Saved, the list is read again. The server names a reason in snake case and the keys of what it sends are put in camel case on the way in:utils/recommendations.js#weightKeyis the one place that knows. The last slider is not a reason: how lately a person must have been here to be suggested at all, over the steps the server gives, from the last month to any time. A year unless the viewer says. - The reason is always shown.
utils/recommendations.js#reasonTextputs the first reason into words from what the service sent with it ("Followed by Ana and 2 others"), and counts where it may name nobody. - Not interested takes a suggestion out at once and tells the service, which does not make it again.
- What the viewer is recommended is chosen on the Recommendations page
itself, from the cog beside the weights
(
containers/recommendations/PreferencesDialog.jsx): whether people, groups and events are suggested, whether what they like and post is used, and the suggestions they removed, each to be taken back. - Whether they are suggested to others is a privacy choice, and is the
one switch in Settings › Privacy
(
containers/actors/Settings/Suggestions.jsx).
Videos
A video post plays in components/VideoPlayer.jsx, the browser's own
player with a cog in its top corner that opens the sizes in a menu, the one playing ticked. Posting one is a composer of its
own, because a video is too big to be sent with the post (the services'
docs/videos.md).
- Whether there is video at all is the installation's choice. Where it
is off, the server leaves the video feature off every profile, so there
is no Video composer and no Videos tab, and nothing here has to hide
them. NodeInfo's
videoUploads,videoMaxBytes,videoMaxSeconds,animationMaxBytesandanimationMaxSecondssay what is allowed (utils/videos.js). - Posting (
containers/media/Composer/Video): the file is refused at once if it is not a video or is too large.useVideoUpload.jsthen begins the upload, cuts the file into parts and sends them to storage three at a time, each tried again when it fails, asking for addresses a few at a time. The page has to stay open for that, and says so. When everything is sent the server prepares the video, and the page can be left: the composer asks after it and says when it is posted, and the + button has a ring round it until then. - Videos on the way are listed in the composer: ones still being prepared, and ones that failed in the last day, each with why.
- The parts go to storage, not to the API, through an axios instance
of their own (
api/videos.js#sendPart) that sends no cookies and none of the API's handling. - Playing: a video is kept at a few sizes. It starts at 720p, at the
smallest where the browser says the connection is slow, or at the size
chosen last time, which is kept in the browser (
utils/videos.js#pick). 1080p and above are played only when chosen. Changing size carries on from the same moment. Nothing changes size while playing. - A video's files are paths under the API (
video.files[].url,video.poster), which ask each time whether the viewer may see the post and send the browser on to storage. The player is a plain<video>, so the browser sends the session cookie itself. - A GIF or an animated PNG is posted from the Video composer too,
which lists them with the video formats and holds them to their own,
smaller limits (
utils/videos.js#problem). The server makes it a silent clip and marks the postvideo.loop. Where video is off there is no Video composer, so none can be posted; the Photo composer takes JPEG and PNG pictures as before, and keeps an animated PNG still. A picture that does not move is refused by the server asstill, and the composer says to post it as a photo. - A looping post plays in
components/LoopingClip.jsx, whichVideoPlayerhands it to: muted, on a loop, with no controls or sizes. It plays only while at least half of it is in view and stops when scrolled away, so only the few on the screen move, not every one on a long page. Where the reader asked for less motion (prefers-reduced-motion) it waits to be pressed. The whole picture is one button that stops and starts it, and a small "GIF" sits in its corner. - A video is a kind of post like the others:
/videos/:id, thevideosnamespace in the store, and the kindvideoin custom feeds.
Downloading
A photo or a video has a Download button in the row under it on its own
page, where the server said this viewer may download it
(authorized.download; utils/downloads.js). Whoever posted it decides,
and it is off until they say otherwise. It is not on a post's card in a
feed or a list: a card is narrow, and one more button does not fit on a
phone.
- When posting, the photo and video composers have a download button
among the post's options (
components/DownloadOptionButton.jsx), which starts on what the profile being posted on says. - Afterwards, "Allow download" and "Do not allow download" are in the
post's menu on its own page, for whoever may edit it
(
containers/controls/medium/Download.jsx). - The default is a switch under Settings › Permissions, for a person
and for a group, not for an event
(
containers/actors/Settings/DownloadDefault.jsx). - The download sends the browser to the API's address for the file, which answers with something to save, so the page stays where it is. A post with several images offers each in a short menu.
Download your data
Settings › Account › Download your data
(containers/actors/Settings/DataExport.jsx), on the viewer's own
settings: a copy of everything they put here, as one file, from the
installation's export service (/exports; the services'
docs/data-export.md).
- Asking opens the step-up dialog (
containers/auth/StepUp) when the server says a fresh proof is needed, and asks again once it is given. - While it is made, the card asks after it every five seconds and can be left; it can be cancelled.
- When it is ready, the card says how big it is and until when it is kept. Download asks the server for the file's address at the press: the address is good for a few minutes, so one read with the page may have run out.
- One at a time, and one a week unless the installation says
otherwise. The card says when the next can be asked for
(
utils/exports.js#canAsk). - Where there is no export service, the card says the download is not
available here (
utils/exports.js#isAbsent).
People in a photo or a video
Who is in a photo or a video (roadmap row 103) shows on its page, right
under the picture or the player, and in the theatre above its words:
"With Worf and Dax", each a link, joined as the reader's language joins a
list (containers/media/PeopleInPost.jsx, api/peopleTags.js). A tag
waiting for somebody shows only to them and the post's author, marked as
waiting; the person tagged accepts or declines it there, or takes
themselves out later with Remove me. Not on cards in a feed, which would
ask the server once for every card. Who may tag whom is anahita-services'
docs/permissions.md.
Its author tags people with Tag people: in the composer, among its small
buttons once there are photos, the people chosen shown as chips and tagged
as soon as the post is made; and from a photo's or a video's menu, which
adds and takes off (containers/media/TagPeopleDialog.jsx). Each person
decides: tagged at once, asked first, or not at all, and the author is told
who is asked and who cannot be. People are found by name or by handle.
Locations of a new post
The pin among a composer's small buttons, Add location, in the short box
and the extended composer, tags a new post with up to five locations
(containers/media/Composer/usePostLocations.jsx, LocationPicker.jsx; roadmap
row 82, part 3). Not replies, which belong to their thread, and not a video,
whose post is made only once its upload is ready.
- Choosing: a popover with a search as one types, the arrow keys and Enter, and a Near me chip that asks the browser for one position only when it is tapped, to put the nearest first; it is used for that search and kept nowhere.
- A new one: the last line, Add "…" as a new location, for those who
may add one (
LOCATIONS_FROM), opens the Add locations form with the name filled in, and what it makes is chosen. - Shown as chips under the text, removed with their ✕.
- From a photo's position: when a photo with a GPS position is picked
for a post, the browser reads it (
utils/photoPosition.js, withexifr, loaded only then, reading only the start of the file), and the popover offers This photo has a location. Suggest locations near it? Only when that is tapped is the position used, once, as the point of that search, and as the pin's start if a new location is added; it is never shown, sent elsewhere or kept, and the file uploaded is the one chosen, stripped on the server as always (roadmap row 82, part 5). - Kept with the draft in this browser, one list for each profile, shared by the short box and the extended composer, so they come along when one becomes the other.
- On posting they are sent with the post (
location_ids), and the server ties them as it makes it, before it goes to other servers, which it then goes with: the first as itslocation, all asPlaces in itstag(the user, 2026-10-19: choosing them is the author's consent, for that post; part 3b). Where the post will go out, a line under the chips says so. A draft kept on the server is tied to them before it is published. A form sends a list as the same key once for each item (utils/api.js#constructFormData).
Link previews
A note with a link in it can carry a card for the link: the page's picture,
site, title and a line about it (components/LinkPreviewCard.jsx). The
card comes from the installation's link preview service (/link-previews;
the services' docs/link-previews.md), which is optional: with none there
is simply no card.
- While a note is written,
containers/media/Composer/useLinkPreview.jsfinds the first link once the writer has paused for half a second, or at once when it was pasted, and asks for its card. The ✕ on the card posts the note without it. A link with nothing to show, or whose card was taken off, is not asked for again. - While a note is edited on its own page the same hook is used, given
the card the note already has, which it shows without asking again. A
different link gets its own card, and the ✕ saves the note without one
(
link_preview_remove). - A reply is a note and has the card the same way: the reply box
(
containers/replies/ReplyForm.jsx) uses the same hook, for a new reply and for one being changed, and a reply shows its card under its text. - The note is sent with the link only (
link_preview_url). What the card says is what the server read off the page, never what the browser sends. - On a post, the card is
linkPreviewon the node, drawn under the text on the feed, on cards and on the post's own page. Its picture is the installation's own copy (utils/linkPreviews.js#imageOf), so reading a note asks nothing of the other site. - A link the player plays (YouTube, Vimeo and the others in
components/Player.jsx) keeps the player and has no card (utils/linkPreviews.js#isShown).
Titles and tags
components/HeaderMeta.jsx keeps the tab's title and the page's tags in
step as the viewer moves about. A site a link is pasted into never sees
them: it runs no JavaScript. What it reads is written into index.html by
the services' web-shell, when the app is hosted through it
(Deploying). The site's name and
description in the build's index.html are what the shell uses for pages
with nothing of their own to say.
Privacy and permissions in settings
A profile's settings keep two things apart:
- Privacy is who can see what: who can see the profile, whether
reposts show on it, who is blocked, and whether the person is suggested
to others. (Its key in the code and in the address is still
access.) - Permissions is who can do what: who can post and reply, who can quote, and whether new photos and videos start out downloadable.
A setting goes where its question does. What somebody is shown, such as which recommendations, is neither, and is chosen where it is shown.
Sharing
Share is the paper plane. It opens one dialog for the whole app
(containers/share/Dialog.jsx, drawn once beside the routes) with the link
and the ways to send it: copy it, email it, post it on Mastodon or Bluesky,
and, where the browser has one, the device's own share sheet ("More
options"), which has every app the person uses. Mastodon asks for the
person's server the first time and keeps it in the browser.
- Anything on a page opens it through
utils/share.js#open:ShareAction,ShareButtonandShareMenuItemincontainers/controls/Share.jsx. A menu can close behind its item, since the dialog is not the menu's. - Where it is: under a post, in the row with like, reply and repost, in a feed, in a list and on its page; a profile's menu; a shared custom feed.
- Only what a visitor could open is sent beyond the site. Something that is not public, and anything on a members-only site, is offered to copy and nothing else, with a note saying who can open the link.
- Nothing is counted and nothing is sent to the server. The links to other networks are plain addresses, and no script of theirs is loaded.
The width of a page
App.jsx wraps every page in one Container, which sets the most a page can
be and its gutters. Inside it a page chooses its own width.
components/PageColumn.jsx is the usual choice: the whole width on a phone,
two thirds of the page, centred, on a wide screen, or all of it with wide.
Home, the pages of custom feeds, a post's own page and the event forms use
it. A profile, with its side column, lays itself out.
Describing photos
Each image of a photo post has its own description, for people who cannot
see it (altText on each of medium.files).
- Writing it is a field under each photo in the composer and in
"Edit photos" (
components/PhotoFilesEditor.jsx). The photos are a row that is swiped through one at a time, like the slides a reader sees, each with its place in the order and the way to remove it; a photo just picked is the one shown. Under a photo with no description is a prompt, "Add an alt description", with why in a line beneath it; pressing it opens the field, which is called "Alt description" so that it is not taken for the post's own description just below. It is never required. - Hearing it: it is the
altof the image wherever a photo is drawn (utils/photoFiles.js#altOf, which falls back to the post's title). - Reading it:
components/AltBadge.jsxputs "ALT" in the corner of a photo that has a description of its own (descriptionOf, which does not fall back), and shows it when pressed. It sits over photos that are links or open the lightbox, and pressing it does neither.
Pictures that are no longer kept
The server removes the pictures of a post from another network once the
post has been left alone for a while, and copies them again when asked. A
post whose pictures are gone has no files and carries
mediaRemoved: { count, alts, gone }.
components/RemovedPictures.jsx is drawn where the pictures were: how
many there were, what each showed, and a button that asks for them
(api/federation.js#restorePictures, which goes to whichever service the
post came through). When they are back it reads the post again and draws
them itself. On the post's own page it asks at once. When the answer is
that they are no longer to be had, it says so in place of the button.
New posts in an open feed
A feed that is open asks again, now and then, for its first page
(containers/feed/useNewPosts.js, for the home feed and every custom
feed).
- It asks less and less often, and stops. After 15 seconds, then 30,
60, 120 and 240; when the last of those finds nothing, it asks no more
(
utils/heartbeat.js). Finding something begins it again at 15 seconds. So does the reader coming back to the tab or the window, which is asked at once. Nothing is asked while the tab is hidden. - At the top of the page, what is new is put in at once. Further
down, the page is left alone, since a page that grows above what is
being read loses the reader's place: a button, "3 new posts", stays in
sight (
containers/feed/components/NewPosts.jsx) and puts them in and goes to the top when pressed. - In the store it is
<FEED>_BROWSE_PREPEND(reducers/createFeed.js): the page goes before what is held, in the server's order, and what is held already is brought up to date. When none of the page is held, more was posted than a page holds, and the feed is begun again from it.
Notifications
The bell in the top bar says how many notifications are unread, and leads
to /notifications, the list of them. What the services tell, and to
whom, is in anahita-services' docs/notifications.md; none is ever
emailed.
- The bell asks; there are no sockets (
containers/notifications/useUnreadCount.js). It asks at once when the app opens, when the tab is looked at again, when the window comes into focus and when the connection comes back; then after 15 seconds, 30, 1 minute, 2, and every 5 minutes after that (REACT_APP_NOTIFICATIONS_CHECK_INTERVAL), for as long as the tab is looked at, and never from a tab that is not. Something new, or the viewer doing anything that tends to bring answers (a post, a reply, a follow: any request but aGET, theirs or for notifications), begins it again at 15 seconds. Each wait is a fifth longer or shorter at random, and a failure keeps the last count. All of it isutils/heartbeat.jswithholdLastandspread. - One tab asks for all of them. The tab being looked at takes a Web
Lock (
anahita-notifications-check) and tells the others the count over aBroadcastChannel(anahita-notifications); when it is hidden or closed, the next one looked at takes over. Where a browser has neither, each tab asks for itself. - The count is in the store (
reducers/notificationsUnread.js), with when the newest was told (newestAt, fromGET /notifications/unread-count). Marking one read or deleting an unread one lowers it at once. The list, when open, loads again from its start when something newer comes. - The bell's menu (
containers/notifications/Menu.jsx), on a desktop: the latest eight as on the page, grouped and with their actions, Mark all read and See all. Following a link in it reads that one and closes it. On a phone the bell goes to the page. - What one is told about is a person's own Notifications section in
settings (
actors/Settings/NotificationChoices.jsx): eight kinds of thing, on or off, each saved as it is switched. - The list is
containers/notifications/Browse/. Each kind has its words innotifications(notifications:replyAddforreply_add), andconstants/notifications.jslists the kinds the services send, which a test keeps in step with the words; a kind the app does not know saysnotifications:other. Its links stay in the app, unread is said in words to a screen reader, and the next page begins after what is on the page, so deleting one skips nothing. - Read when opened: following any of a notification's links marks it
read, quietly (
Browse/ListItem.jsx,onClickCapture); seeing it in the list does not. A profile with no name yet is named by its handle. - Several as one: likes of one post, reposts of one post and follows of
one profile come as one line (
ids,others,others_count), "dax and 1 other liked your post" (notifications:voteupOthers, in each language's plurals), with their pictures overlapping. Reading, opening or deleting it does so to all of them, and the bell asks again how many are unread; the next page begins after every row on the page. Marking one read keeps its line whole (reducers/notifications.js). - Actions under a notification (
Browse/Actions.jsx), as the server says they are still to be done (actions): Decline and Accept a request to follow, or a request to be tagged in a photo or a video (answer_tag, roadmap row 103), + Follow whoever followed, and Reply, which opens the reply form under it and answers the reply itself, or the post. Using one reads the notification. - Mark all read, in the page's header (the icon alone on a phone),
marks every one read (
PATCH /notifications/read-all), the list and the bell with them. - Get notifications in a post's menu subscribes to its replies
(
containers/controls/medium/NotificationSub.jsx), and Stop notifications ends it.
Custom feeds
A custom feed is one somebody builds: where its posts come from, what they
are about, what to leave out. The server's side is in the services'
docs/feeds.md. The words a person reads are always "custom feed", never
"algorithm".
- Home tabs. The first tab is Following, the feed there always was
(
containers/feed/Leaders). After it, the feeds fromGET /feeds/pinned, in the viewer's order. The open tab is in the address as?feed=<id>. The way to/feedsis "Feeds" in the left menu; the tabs have no button of their own for it, which looked like a setting (the user, 2026-10-19). - Back to the top. Once somebody has scrolled about a screen down Home
or a profile, an arrow fades in at the bottom left, across from the +
button, and takes them back up (
components/BackToTop.jsx). - One column or several. On a wide screen a pair of buttons at the end
of the tabs lays the open feed out one post under another or in columns
(
components/BreakpointMasonry), when it takes the width of the page. The choice is kept in the browser (home.feedView). On a narrow screen there is one column and no buttons. - One set of cards.
containers/feed/components/Items.jsxdraws a feed's posts and reposts, with their menus and buttons, for Following and for custom feeds alike.containers/feed/Customis the list for a custom feed, given a savedfeedor an unsaveddefinition. - Paging. A custom feed is paged by a cursor the server hands back, and
the store's lists are kept by how far down they are.
api/customFeeds.jskeeps each list's cursor and reports a page to the store in the store's terms (feed_custom, besidefeed_leadersandfeed_actor). - Under Home in the left menu are the feeds on the viewer's home page, in
their order, each opening Home on that feed. The list page is an entry of
its own below them, Feeds, like Saved. A feed is made from the + on the Feeds page. A feed is put on its maker's home page when it is made, so it
appears there at once. The menu and the tabs read the same list
(
containers/feeds/usePinnedFeeds.js), again whenever a feed is made, changed, deleted or moved:api/customFeeds.jstells whoever asked (onChange). - The list page (
containers/feeds/Manage.jsx, reached from "Feeds" in the menu): the viewer's feeds and the ones of other people's they keep. - Rows of one height. A feed's row in the list is its name, who can
see it and when it was made, and one line cut short: what it is for, or
what it shows when it does not say. Pressing the name opens the feed in
full in a dialog (
containers/feeds/DetailsDialog.jsx): its description and every setting it has, one line each, with a button on to the feed. - Finding shared feeds: the site's search, and the Feeds tab of
Recommendations (see Suggestions). In search a feed that matches is a
result like any other, among the profiles and posts under All, and by
itself under a Feeds tab (
scope=feeds). Search sends it as a node with afeedblock;containers/feeds/SearchCard.jsxdraws it as a card (components/CustomFeedCard.jsx) in the same columns, with the button that adds it to the home page. Feeds kept to their owners are never found. - The editor (
containers/feeds/Form.jsx) is a form in the page, for a new feed at/feeds/new(Add.jsx) and for changing one at/feeds/:id/edit(Edit.jsx). Both are reached from the list of feeds: the + in its header, and the pencil on a feed's card. Hashtags and keywords are typed as chips (components/WordsField.jsx); profiles and places are searched for and picked (components/NodesField.jsx). People on other servers are found there too, through the site's search, if anybody here has followed or looked them up; the lists of people and groups keep to this site's own. "Posted on" (people, groups, events; people meaning people here and elsewhere) is not offered when profiles named by hand are the feed's only source, since it could only hide some of their posts, and is not sent then. Under the fields is what the feed shows, in a sentence. There is no preview: saving opens the feed, Home on its tab when it is kept there. Everything done to a feed is done from the list page. Home has only the way to it. - A feed's icon is one of sixteen, chosen in the editor from a list that
names each, on the same line as the feed's name, and kept by name
(
iconon the feed; the server'sfeeds.Icons).components/CustomFeedIcondraws it: in the menu under Home, on the tabs, in the list and on the feed's page. - Deleting a feed is offered to its owner in two places, each asking first: its card in the list and its own page. Not in the editor, which is for changing a feed.
- Ten feeds is the most a person owns, the number the home page holds.
- The sentence is
utils/customFeeds.js#summary, from the definition alone. It is on the editor, on a feed's page and in the list, so nothing about what a feed shows is hidden. The same file repeats the server's limits so the editor can say what would be refused before it is sent. The server decides. - A feed's page (
containers/feeds/Read.jsx): add to home, copy, and for its owner edit and delete. Copying makes a feed of the viewer's own, seen by them alone, and opens it in the editor. - Share is on a shared feed's card (in search and in Recommendations › Feeds), in the list of feeds and on the feed's page. See Sharing. Not on a feed kept to its owner.
- Following a hashtag or a place (
containers/feeds/FollowHashtag.jsxandFollowLocation.jsx, on the hashtag's and the place's page; both areFollowFeed.jsx) makes a feed of that one hashtag or place, the viewer's alone, and puts it on the home page, or puts there the one the viewer already has. The feed is given the hashtag's or the place's own picture. It goes into no other feed. A feed of a place shows only what the viewer could see anyway, and who follows a place is kept nowhere. - Naming in requests. The definition's keys are snake case on the server
(
hashtags_any) and camel case here (hashtagsAny), converted both ways by the interceptors like every other body.
Pinned posts
"Pin to profile" and "Unpin" are in a post's menu, on its page and in a feed
(containers/controls/medium/Pin.jsx), where the server says the viewer may
(authorized.pin). A pinned post has a "Pinned" line at the top of its card
(components/PinnedLabel.jsx). A profile has one pin, so pinning moves it:
the change is told to every list on the page (POST_PIN_CHANGED, handled in
reducers/create.js by utils/reducer.js), and the post that had the pin
stops saying so. The new order shows when the list is next read.
Saved posts
"Save" in a post's menu puts it on the viewer's own list; the same item reads
"Remove from saved" once it is (containers/controls/medium/Save.jsx,
isSavedByViewer on the post). The list is a page of its own, Saved in
the left menu under Home, at /saved (containers/saved/index.jsx, the list
in Browse.jsx), in the same masonry as the other lists. It was a tab on the
viewer's own profile for a day and was moved on 2026-10-06: a profile is what
other people look at. There are no folders. Collections, planned for later, would be a separate feature filled from
this list, and would leave it as it is.
It is private end to end: the server never says who saved a post. The page
says so in one line under its title. The mark is told to every list on the page (POST_SAVED_CHANGED,
handled in reducers/create.js).
A post's interactions
"Interactions" is a dialog about a post: who liked it, reposted it, quoted
it and replied to it (containers/activity/PostActivity.jsx,
utils/activity.js). It has a tab for each of the four, with its icon and
its number; under the tabs is the list of who, each row a link. In the code it is still
called activity. Each list is read when its tab is first opened.
It opens from the last button in the row under a post
(containers/likes, drawn by components/PostActionsRow.jsx, which every
layout of a post uses), there to press when the post has any of the four,
and from "Interactions" in a post's menu. There is one row under a post:
like, reply, repost, share and interactions, each the same kind of button
and as wide as the others, so the row is shared out evenly. None is an
icon button. The separate row of counts, of likes and of replies, is gone.
There are no views in it, and nothing anywhere counts them.
The top of a profile
actors/Read/ActorHeader.jsx lays it out the same way on a phone and a
desktop, left aligned:
- The cover, when there is one, as a wide band three times as wide as
it is tall and no taller than 320px (
components/CoverForm.jsx). With none it is not drawn at all, apart from a slim "Add a cover photo" band for whoever may add one. Cards of profiles use the same band. - The picture over the cover's lower edge, or at the top of the card with no cover, smaller on a phone; beside it, the buttons: Follow (filled while not following, outlined once following), the others the viewer may use, and the ⋮ menu. They were a row of their own under everything, which left a band of white.
- The name, with the pronouns after it in a lighter grey, "Sarah Noble (they/them)".
- The handle, and "· Follows you" after it for somebody who follows the viewer.
- One line of counts, "22 followers · 16 following"
(
actors/Read/SocialgraphMeta.jsx), each opening the dialog below on that list. Following is counted only for this site's people: whom somebody on another server follows is not known here.
What the profile says of itself is in its About card, not here.
Followers, following and what is in common
Under a profile's name are its counts. Pressing one opens a
dialog (actors/Read/SocialgraphDialog.jsx, utils/socialgraph.js) with up
to three lists:
- Followers: who follows the profile.
- Following: who the profile follows. Only people follow, so only a
person's profile has it. The code and the API call these
leaders. - In common: the people the viewer follows who also follow this
profile. Only for somebody signed in, on a profile that is not their own.
Each is
read when first opened and more of it as its end is scrolled to, whoever
followed most lately first. The order is a small menu at the right of the
dialog's title, Latest or Oldest (
?dir=asc); it was a row of its own under the tabs. Each tab's count is in grey after its name, as on the search page. A group's administrators can remove a follower there.
What is in common is found by one query in the graph, so it is whole, paged and counted; its number is on its tab once the tab has been opened.
A person who follows the viewer says so: "Follows you" after their handle on their profile, and beside their name in these lists.
One word for following. Every button that follows anything, a profile,
a hashtag or a place, reads "Follow" with a plus before it
(controls/Follow.jsx, feeds/FollowFeed.jsx), except for somebody who
follows the viewer already, where it reads "Follow back" (asked for by the
user on 2026-10-14). Two things keep words of their own because they are not that
button: "Request to follow", which asks and does not follow, and "Follow
" in a post's menu, where what is followed is not what the menu
belongs to.
There is no Social Graph tab. An old address such as
/people/ana/socialgraph/leaders opens the profile with that list showing.
Blocked is not in the dialog: who you have blocked is yours alone. It is
one row under Settings › Privacy (actors/Settings/Blocked.jsx) saying how
many there are, which opens a dialog: everybody blocked, with their picture,
read a page at a time as the list is scrolled, a box to search it by name,
and Unblock on each.
A group or an event has the same row under its own Privacy tab, below Access,
listing who it has blocked; Unblock there lifts the group's block, not the
viewer's. A group blocks somebody from the menu on what they wrote in it:
"Block from group" ("Block from event" in an event), on a post (controls/BlockFromOwner.jsx, in the feed's
menu and the post page's) and on a reply (replies/Thread.jsx, which reads
the thread again afterwards). It is offered where the server says
authorized.blockFromOwner: to the group's administrators, on what somebody
else wrote, and on the post's own page to site administrators too. Pin is
offered the same way. A person's own block does not reach the groups they
run.
Events
An event is an actor like a group, so most of it is the containers a group
uses, told the events namespace: its page (actors/Read), its settings, its
administrators, its access. What only an event has is in containers/events.
| Where | What |
|---|---|
Events in the left menu, /events |
The viewer's own: Upcoming, Invited, Hosting, Past (events/index.jsx). There is no list of every event. Its + follows the installation's eventsFrom (permissions/actor.js), a setting apart from the one for groups |
/events/add, /events/add?host=<group id> |
The form (events/Form.jsx). With a host it is the group's event |
/events/:id |
The actor page with events/Panel.jsx above the tabs: when it is, who hosts it, how many are going, Going and Maybe, Add to calendar. Its administrators change it from its settings |
| Info under its settings | The same form, to change it. The old address /events/:id/edit sends there; calling an event off is in the Danger zone |
| A group's Events tab | What the group hosts, upcoming or past, with Add event for its administrators (events/Hosted.jsx) |
Times are kept as instants with the name of the zone they were chosen in.
The form works on that zone's clocks, whatever zone the browser is in; a
reader sees them on their own, with a second line for the event's zone when
it differs. utils/events.js does both with Intl, clock changes included,
and has the tests for it.
Capacity is set on a slider on the form, not typed: its stops are close
together where the numbers are small and further apart as they grow, and all
the way up is no limit (events.capacityStops). An event whose number is
not one of the stops has it added, so opening it to change something else
does not change how many may go.
Changing and cancelling are in the event's settings, reached from its
menu like a group's. The Info tab is the event's own form
(events/Form.jsx), so the event's page has no Edit button, and
/events/:id/edit leads to the settings. Cancelling is the first card of the
Danger zone (events/CancelSetting.jsx): it cannot be undone, and it is the
thing to weigh against archiving and deleting.
Answering is Going or Maybe, and is following the event, so an event's
page has no Follow button. Not going, Maybe and Going are one group of
buttons the width of the card, the chosen one filled; Not going takes an
answer back. The number going opens who they are
(events/Attendees.jsx). The link to join online is sent by the server only
to whoever is going or looks after the event.
Where it is is an address typed on the event's form and kept on the
event, not a place on the site's map: places are public, and an event at
somebody's home should not put their home on it. The server sends the address
only to whoever is going or looks after the event, and the About card on the
event's page shows it (actors/Read/About.jsx), which is drawn for an event
with an address even when it has no description. Everybody else is told there
is an address for people who are going.
A map is drawn in that card when the address came with a point. The form
has a "Show a map" checkbox, off unless ticked, because finding an address
means the server sending it to a geocoder. The marker opens nothing
(linked={false} on components/Map.jsx). Pressing the "Where" row opens a
menu of Apple Maps, Google Maps and OpenStreetMap (events.mapLinks). Each
opens at the point when there is one, and otherwise looks the address up, from
the reader's own browser and only when picked. The event's website, a field
on its form, is the row above "Where", so the map sits right under the
address. A public venue can still be tagged from the
event's Locations tab, and the panel names it.
An event for a group's followers. An event hosted by a group has a
checkbox on its form: everyone who follows the group can see it and answer.
A follower who has not answered is shown its name, time and description with
Maybe and Going (events/Answer.jsx), on its page and in the group's Events
tab. Once they answer they follow the event, and its page opens to them.
No avatar. An event's picture is its cover. Wherever an avatar would be
drawn, components/ActorAvatar.jsx draws EventDateTile for an event: the
month over the day it starts on, on the reader's calendar, or a calendar icon
where the event is named without its times. Its page has no avatar to upload.
Add to calendar asks the server for the event as an .ics file and hands
it to the browser to save.
Inviting people to a group
A group's page has Invite for whoever the group lets invite
(authorized.invite). It opens containers/actors/Invite: the viewer's own
followers, found by name and ticked, up to 50 at a time. The server answers
for each person, and its answer is shown beside the name (utils/invites.js).
Whoever looks after the group has a second tab, Invited, with who is still
to answer and a way to take an invitation back.
The person invited answers with containers/controls/InviteAnswer.jsx,
Decline and Accept, in three places: on the notification, on the group's page,
and on the limited card when the group is one they could not otherwise see.
Who can invite is set under Settings › Permissions. The old "Who can add a follower?" row is gone: nobody is added without saying yes. The "+ Followers" button and its picker went with it.
Private profiles and asking to follow
A profile the viewer may not see is refused by the server, unless it lets
people ask to follow it. Then the server sends its name and picture, marked
restricted, and the web app draws components/LimitedActorCard.jsx: as the
card in the lists of people and of groups (actors/Browse/Card.jsx), and as
the whole page when the profile is opened (actors/Read/index.jsx).
The button on it is containers/controls/FollowRequest.jsx: "Request to
follow", then "Requested", which takes the request back when pressed. Whoever
looks after the profile answers from the follow requests on their own
profile, and is told by a notification.
The home page of somebody not signed in
assets/default/home, the theme's, drawn at / in place of the feeds. It is
about the installation and not about Anahita: its name and description as
its operator set them (NodeInfo's nodeName and nodeDescription, the
build's until that has answered), how many people and posts are on it,
whether it is part of the fediverse and Bluesky, and the two ways in, Sign in
and, where NodeInfo says profiles can be made, Join. It is one column, no
wider than 960 pixels, on the page's own background: no box, cards or
shadows. Under a hairline, what can be done here, three or four short items
side by side (a fourth on an installation that federates); then tiles,
two by two, for what may be looked at without a profile: people, groups,
hashtags and About. That it runs on Anahita is one line at the foot. A members-only installation never draws it
(routes/MembersOnlyGate.jsx).
The fediverse
People on other servers (Mastodon and the like), and what they post. The
server's side is activitypub-service, described in the services'
docs/federation.md. It is optional: where there is none, everything here
draws nothing (utils/federation.js#isAbsent).
-
The composer says when a note will stay here. Somebody on the fediverse, writing a note on their own profile for fewer than everybody, is told above the Post button that it will not be shared with the fediverse (
media/Composer/useOnFediverse.js, asked once and kept up to date by the switch in settings). Nothing is said to anybody else, or about any other kind of post: only public notes go out. -
A thread written in one go (roadmap row 101): under a note's words, "Add to thread" opens a box for the next note, up to ten in all (
Composer/ThreadParts.jsx,THREAD_PARTS_MAX). Post sends them together (api.replies.addThread,POST /notes/:owner_id/thread): the first takes the post's audience, who may reply and quote, its locations and its card; the rest are replies to the one before. Only for words alone: adding photos or a video hides it. The notes after the first are not kept as a draft. A new reply can be a thread too (replies/ReplyForm.jsx, sent as{ thread }toThread.jsx,POST /notes/:id/replies/thread); its notes are shown as any replies are, each under the one before. -
What other networks will show of a public post on the viewer's own profile is said in a line under it (roadmap row 100,
Composer/Short.jsx): past 300 characters, when their Bluesky account is connected, that Bluesky shows the first 300 with a link to the rest; with more than four photos, that Mastodon, Bluesky or both show the first four with a link to all of them. The Bluesky line's room is kept while the post goes there, so nothing moves as the words pass 300. The numbers areBLUESKY_POST_MAXandPICTURES_SHOWN_MAXinconstants/limits.js; the count is of what is typed, Markdown's marks included, a little more than Bluesky is sent. -
Answering something from another server is offered where the server says the viewer may (
authorized.comment), as for any post: the reply is the viewer's own note and is sent to whoever it answers when the viewer is on the fediverse. When they are not, the reply form says that it will be read here and not there (useReplyStaysHere, inmedia/Composer/useOnFediverse.js). Liking and reposting something from elsewhere work the same way, where the server allows them (authorized.like), and reach its author's server. -
A hashtag at
/tags/<name>leads to/hashtags/<name>(routes/index.js#HashtagMoved). It is the address other servers are given for a hashtag's page, where Mastodon has its own, because some of the apps people read with know a hashtag's link by that shape. -
A profile from another server has three lists, what was posted, Notes and Photos (
utils/node.js#getActorFeatureTabs,REMOTE_TABS): a post of theirs that came with pictures is a photo post here. Articles and videos are not taken in from other servers, so those lists would be empty for ever and say the person has none; each comes back when it is taken in. Their replies and reposts are in what was posted, not in lists of their own. Its box of locations is the site's administrators' to fill, as it is for any profile here, to say where an organisation on another server is (locations/Gadget,hideWhenEmpty); nobody else sees it while it is empty. -
The servers the site refuses are in Site settings, under Fediverse (
settings/Fediverse.jsx), for super administrators as every tab there is: the list with why, and a button to stop refusing one. The list is read fifty at a time as it is scrolled and searched on the server, by name or reason, since an installation that takes up somebody else's list has thousands; a server is refused from a dialog, which says that refusing it does not remove what already came from it. -
The switch is a card under Settings › Fediverse, beside the Bluesky card, on the viewer's own profile only (
actors/Settings/Fediverse.jsx): "Let people on other servers find and follow me". Off until the person turns it on, and theirs alone to turn on. It shows their handle, says that what is sent cannot be called back, asks before it is turned off (other servers are told to remove the person, and may not take them back), says that making a profile private has the same effect, and when they cannot turn it on (their profile is not open to everybody, or the site does not federate) it is disabled with the server's reason beside it. -
Closing a profile is the one way to leave by accident, so the card where a profile's audience is chosen (
actors/Settings/Access.jsx) asks whether the person is on the fediverse, warns beside the choice as soon as anything but "everybody" is picked, and asks once more before saving. It is the only choice on that card that is confirmed, because it is the only one that cannot be undone. An administrator changing somebody else's profile is warned the same way. -
Somebody from another server is a person like any other to the app: their type ends in
person, so they are opened at/people/<handle>, where the handle is whole,alice@mastodon.example, and the same page draws them. What differs is told byutils/federation.js:- their handle is drawn whole (
displayHandle), where a username here is made safe first; - their About card says which network they are on, in a row with the
network's mark (the butterfly for Bluesky, the hub for the fediverse),
what of them is kept here, and a way to their own page there
(
actors/Read/About.jsx). Nothing is added to the header: it is a fact about the profile, not something to act on; - the server sends nothing they can be edited, run or posted on by, so none of those controls is drawn. Follow is, and works as for anybody.
- their handle is drawn whole (
-
A post from another server is an ordinary note with a
uri. Its menu has "View on " (media/MediaMenu.jsx). It cannot be edited here, whatever the viewer's role (permissions/node.js#canEdit): it is its author's to change, on their own server. It can be liked, replied to and reposted wherever the server allows (authorized). -
Looking somebody up is on the search page (
search/RemoteLookup.jsx): when what was searched for is a handle with a server in it or the address of a profile, a card offers to ask that server. It asks when the button is pressed, since asking tells another server that this site did; then the person found is drawn first among the search page's results, with their card and its Follow button. It asks at once, without the button, when somebody signed in typed an unmistakable handle into the search box and pressed Enter (isClearHandle:@alice@mastodon.example, a Bluesky DID, a Bluesky handle written with@or onbsky.social, or the address of a profile,https://pixelfed.social/dansup, which is what Pixelfed and others show people). The box says so in the router's state; a link to the same search does not, so a link cannot make this site call another server just by being opened. See Search. -
A lookup that fails says what happened and what to try, naming the server (
federation:lookup.errors): nobody by that name, a group or a post rather than a profile, a server this site does not connect to, or somebody on this site are answers, said with no button; a server that did not answer, or too many lookups, may pass, and offer Try again (utils/federation.js#isRetryable). A search of this site that fails says so in a sentence with Try again, never the request's own message; and a follow or an unfollow that fails gives the button back and says so (controls/Follow.jsx). -
Bluesky is read through a service of its own, which may be there when the fediverse's is not, or the other way round. To the app, somebody on Bluesky is somebody from another server like any other. What differs is in
utils/federation.js:- which service a lookup asks is told by how the handle is written
(
lookupKind):@alice@mastodon.examplehas its server after a second@;alice.bsky.social, adid:plc:…or an address underbsky.app/profile/is Bluesky.api/federation.js#lookupsends it to/activitypub/lookupor/atproto/lookup, and the card goes for the one network whose service is not there, not for both; - the page of a post or a person (
pageOf): on the fediverse the address a thing has between servers is its page; on Bluesky it is anat://address, and the page is worked out from it. "View on …" leads there, in the menus and on the profile; - a reply to a post from Bluesky stays here unless its writer
connected their Bluesky account:
useReplyStaysHereanswers with the words to say so under the reply being written, and with nothing for somebody connected (useOnBluesky, asked once and kept up to date by the settings card).
- which service a lookup asks is told by how the handle is written
(
-
A person's own Bluesky account is connected in their settings, under Fediverse (
actors/Settings/Bluesky.jsx): a handle and an app password, with a link to where Bluesky makes one, then "Connected as" and a button to disconnect, which asks first. When Bluesky no longer takes the connection the card says so and offers the form again. Once connected, a second button follows here the accounts they follow on Bluesky already. It asks first: a dialog says how many they follow there, offers a number (the 25, 100 or 500 followed most recently, or all of them when there are fewer), and says what it does and that it is not undone in one step. The server carries on with it after answering. Nothing is drawn where the installation does not write to Bluesky.
Search
- The box in the top bar (
components/SearchBox.jsx) is an MUIAutocompletein free-solo mode. Searching opens the search page through the router, without reloading the app, and marks the search as typed (see Looking somebody up, above). - Recent searches (
utils/recentSearches.js): focused while empty, the box lists the last ten searches, newest first, each with the tab it was run under; typing narrows them; each can be forgotten, or all at once. The search page lists them too when nothing has been typed (search/Browse/RecentSearches.jsx). They are kept in this browser only, under a key with the person's id, and cleared when they sign out. Nothing about what somebody searched for reaches the server, so they do not follow somebody to another device. A search is remembered when it is run, however it was reached. - The controls: the order as a small menu with, beside it, the two choices below as chips; the row wraps on a narrow screen. The distance is a slider on a line of its own under them (25 to 100 km, or anywhere), written to the address when it is let go.
- Two choices for "Most relevant" (
utils/searchChoices.js), chips shown only with that order and only to somebody signed in: Profiles I follow first (people, groups and events they follow, and what is posted on them, before everything else) and Newer results first (the newest first among results that match equally well). Off as they come, kept in this browser per person, and sent with a search only when chosen (followed_first,recent_first). - The page keeps its place in its address (
utils/searchAddress.js):?q=seeds&scope=feeds&sort=recent&km=50. Reloading, going back and a link sent to somebody open it as it was. What is as it comes is left out. Changing the tab, the order or the distance replaces the address rather than adding to the history. - The tabs are All, Posts, Media, Profiles and Feeds (
scopeall,posts,media,actors,feeds). Posts is every kind of post; Media is photos and videos alone, as other networks mean it (the user, 2026-10-20). - Each tab says how many it finds, in grey after its name and short
however large (
utils/shortCount.js: 515, 1.2K, 48K, 3.1M, as the reader's language writes them). One request for each tab, asking for a single result and reading the total (search/Browse/useScopeCounts.js), again when the words or the distance change but not the order. A tab that finds nothing is not drawn, except All and the open one. - A search that finds nothing says so (
search/Browse/NothingFound.jsx), and offers the other tabs that would find something, from the same counts. A handle from another server is left to the lookup card instead. - A distance needs a place. The server leaves the distance out when it is not told where the searcher is, so when the browser cannot say or was not allowed to, the page says that the search is not limited by distance, rather than search everywhere and look as though it had not. While the browser is still asking, it waits.
Cards in lists
The cards that show posts in lists, components/MediumCard.jsx (search,
hashtags, places, saved, a profile's tabs) and the feed's
(feed/components/Default.jsx), are drawn alike:
- A title (an article's, a topic's, a photo's) through
components/PostTitle.jsx: one size, a little larger and bolder than the text, in the text's colour, a link with no underline. - The title and the text open the post when clicked
(
utils/openOnClick.js), unless text is being selected, so it can still be copied, or the click was on a link or a button in it: a hashtag, a mention, an address. Ctrl-, ⌘- or a middle click opens it in a new tab. The title stays a real link for the keyboard and screen readers. - Where it was posted, a group or somebody else's profile, as a quiet
line above the author (
components/MediumOwnerCardHeader.jsx), not a second header as heavy as the author's. - What a reply answers is quoted in plain words (
utils/plainText.js), so a link in it reads as its words, not as Markdown written out. - Raised the same way as every other card and the gadgets beside them: MUI's default variant and elevation, set by nobody. Only a post quoted inside a repost is outlined, to stand apart from the card it is in. The composer above a feed is raised too, and the form's own card in it lies flat on it.
Long posts
A long post on a card shows its first lines and Read more
(components/ReadMore.jsx): six lines on a post, four on a profile card.
The whole text is always drawn and only its height is kept short, with the
last line fading out, so nothing is cut through: a link or bold text near
the cut stays whole, and a screen reader has all of it. Read more is shown
only when the text is taller than that, measured as it is drawn and again
when a picture in it loads. On a card, a text of more than 1000
letters as read leads to the post's or the profile's own page, where the
whole text is: opened in place, a long text made its card many times taller
than the ones beside it and the columns jumped. A shorter one opens in
place.
Menus
Every item in the menus on posts, feed items, replies and profiles has an
icon before its words, through components/MenuItemLabel.jsx.
Quote posts
A quote is a note that carries another post under its own words. It is an ordinary note in every other way: its own audience, likes and replies.
- Making one. The repost button under a post opens a small menu, upward:
Repost, or Quote (
containers/controls/Repost.jsx). Quote is switched off where the server said this viewer may not quote this post (authorized.quote). It openscontainers/controls/QuoteDialog.jsx, which posts a note on the writer's own profile withquote_id. A refusal at that point is said in the server's words (utils/quotes.js). - Showing one. A note that quotes is sent
quote: the post, or why it is not shown (unavailable,detached).components/QuoteEmbed.jsxdraws either, inside the note, in feeds, lists and on the note's page. The server sends the post only to a reader who may see it. - Taking a post out. On the page of a note that quotes their post, the
quoted author has "Remove my post from this quote"
(
containers/controls/QuoteDetach.jsx). It cannot be undone. - Who can quote is the third part of a post's interaction settings
(
components/ReplyAccessDialog.jsx): anyone, the author's followers, or nobody. A post that has never been asked follows its author's own setting, "Who can quote my posts", under Settings › Permissions (containers/actors/Settings/QuotePolicy.jsx).
There is no comment code left: the comment components, actions, reducers,
API modules and prop types went with the comment service. Two names remain
from then, because the server still uses them: authorized.comment and
commentCount.
The rules are the server's, in the services' docs/permissions.md, Replies.
Translations
Text is translated with i18next (src/languages/). Each language is a
directory named for its locale, such as en-CA or fr-FR. Each file in it is
a namespace, such as people.js or settings.js, used as
i18n.t('people:settings.info').
en-CA, fr-FR, de-DE, es-419 and pt-BR are bases, with every string; en-US and fr-CA are
layers, with only the strings that differ, and fallbackLng reads the rest
from the base. The language is chosen from the browser's when the app starts
(src/languages/choose.js), and REACT_APP_LANGUAGE is the default when the
browser asks for none of them. When it changes, src/languages/index.js sets
moment's locale and the page's lang. Code that formats a date or a number
with Intl or toLocale… passes i18n.language. A post processor makes the
space before ?, ! and ; in French (src/languages/frenchSpacing.js).
See Languages.
Styling
The UI is Material UI 9 (@mui/material), styled with Emotion. The theme is
made in src/styles/index.js with createTheme, from the options the active
theme's styles.global() returns (see Themes), and
provided in src/containers/Root.jsx. Under the theme's options,
src/styles/index.js puts back a few Material-UI 4 defaults, such as the
breakpoint widths and the background colours, so that the app kept its look
through the upgrade; a theme can override any of them.
Components style themselves in one of two ways:
makeStylesorwithStylesfromtss-react/mui, which return class names built from the theme. Most existing components use these.- The
sxprop, for a few rules on one element.
Styles are plain objects. theme.spacing(n) returns a string with its unit
('16px'), so write theme.spacing(-2), not -theme.spacing(2), and don't
add px after it.
Material UI 9 supports Chrome 117, Edge 121, Firefox 121 and Safari 17 and
later; browserslist in package.json matches.
From anahitasocial/anahita-web, 2026-10-27.
Written by Claude, reviewed by Rastin Mehr · About this