diff --git a/app/components/Avatar/AvatarWithPresence.js b/app/components/Avatar/AvatarWithPresence.js index 39b76038..39d2e05b 100644 --- a/app/components/Avatar/AvatarWithPresence.js +++ b/app/components/Avatar/AvatarWithPresence.js @@ -17,6 +17,7 @@ type Props = { isEditing: boolean, isCurrentUser: boolean, lastViewedAt: string, + profileOnClick: boolean, t: TFunction, }; @@ -65,7 +66,11 @@ class AvatarWithPresence extends React.Component { : undefined} /> diff --git a/app/components/Collaborators.js b/app/components/Collaborators.js index b5f65ba6..7841df80 100644 --- a/app/components/Collaborators.js +++ b/app/components/Collaborators.js @@ -1,79 +1,93 @@ // @flow -import { sortBy, keyBy } from "lodash"; -import { observer, inject } from "mobx-react"; +import { filter } from "lodash"; +import { observer } from "mobx-react"; import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; -import { MAX_AVATAR_DISPLAY } from "shared/constants"; -import DocumentPresenceStore from "stores/DocumentPresenceStore"; -import ViewsStore from "stores/ViewsStore"; import Document from "models/Document"; import { AvatarWithPresence } from "components/Avatar"; +import DocumentViews from "components/DocumentViews"; import Facepile from "components/Facepile"; +import NudeButton from "components/NudeButton"; +import Popover from "components/Popover"; +import useStores from "hooks/useStores"; -type Props = { - views: ViewsStore, - presence: DocumentPresenceStore, +type Props = {| document: Document, currentUserId: string, -}; +|}; -@observer -class Collaborators extends React.Component { - componentDidMount() { - if (!this.props.document.isDeleted) { - this.props.views.fetchPage({ documentId: this.props.document.id }); +function Collaborators(props: Props) { + const { t } = useTranslation(); + const { users, presence } = useStores(); + const { document, currentUserId } = props; + + let documentPresence = presence.get(document.id); + documentPresence = documentPresence + ? Array.from(documentPresence.values()) + : []; + + const presentIds = documentPresence.map((p) => p.userId); + const editingIds = documentPresence + .filter((p) => p.isEditing) + .map((p) => p.userId); + + // ensure currently present via websocket are always ordered first + const presentUsers = filter(users.orderedData, (user) => + presentIds.includes(user.id) + ); + + // load any users we don't know about + React.useEffect(() => { + if (users.isFetching) { + return; } - } - render() { - const { document, presence, views, currentUserId } = this.props; - let documentPresence = presence.get(document.id); - documentPresence = documentPresence - ? Array.from(documentPresence.values()) - : []; - - const documentViews = views.inDocument(document.id); - - const presentIds = documentPresence.map((p) => p.userId); - const editingIds = documentPresence - .filter((p) => p.isEditing) - .map((p) => p.userId); - - // ensure currently present via websocket are always ordered first - const mostRecentViewers = sortBy( - documentViews.slice(0, MAX_AVATAR_DISPLAY), - (view) => { - return presentIds.includes(view.user.id); + presentIds.forEach((userId) => { + if (!users.get(userId)) { + return users.fetch(userId); } - ); + }); + }, [document, users, presentIds]); - const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id); - const overflow = documentViews.length - mostRecentViewers.length; + const popover = usePopoverState({ + gutter: 0, + placement: "bottom-end", + }); - return ( - v.user)} - overflow={overflow} - renderAvatar={(user) => { - const isPresent = presentIds.includes(user.id); - const isEditing = editingIds.includes(user.id); - const { lastViewedAt } = viewersKeyedByUserId[user.id]; + return ( + <> + + {(props) => ( + + { + const isPresent = presentIds.includes(user.id); + const isEditing = editingIds.includes(user.id); - return ( - + ); + }} /> - ); - }} - /> - ); - } + + )} + + + + + + ); } const FacepileHiddenOnMobile = styled(Facepile)` @@ -82,4 +96,4 @@ const FacepileHiddenOnMobile = styled(Facepile)` `}; `; -export default inject("views", "presence")(Collaborators); +export default observer(Collaborators); diff --git a/app/components/DocumentMetaWithViews.js b/app/components/DocumentMetaWithViews.js index dc43a53b..92b653f2 100644 --- a/app/components/DocumentMetaWithViews.js +++ b/app/components/DocumentMetaWithViews.js @@ -1,9 +1,13 @@ // @flow import { useObserver } from "mobx-react"; import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; import styled from "styled-components"; import Document from "models/Document"; import DocumentMeta from "components/DocumentMeta"; +import DocumentViews from "components/DocumentViews"; +import Popover from "components/Popover"; import useStores from "../hooks/useStores"; type Props = {| @@ -12,22 +16,41 @@ type Props = {| to?: string, |}; -function DocumentMetaWithViews({ to, isDraft, document }: Props) { +function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) { const { views } = useStores(); + const { t } = useTranslation(); const documentViews = useObserver(() => views.inDocument(document.id)); const totalViewers = documentViews.length; const onlyYou = totalViewers === 1 && documentViews[0].user.id; + const popover = usePopoverState({ + gutter: 8, + placement: "bottom", + modal: true, + }); + return ( - + {totalViewers && !isDraft ? ( - <> -  · Viewed by{" "} - {onlyYou - ? "only you" - : `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`} - + + {(props) => ( + <> +  ·  + + {t("Viewed by")}{" "} + {onlyYou + ? t("only you") + : `${totalViewers} ${ + totalViewers === 1 ? t("person") : t("people") + }`} + + + )} + ) : null} + + + ); } diff --git a/app/components/DocumentViews.js b/app/components/DocumentViews.js new file mode 100644 index 00000000..a060282c --- /dev/null +++ b/app/components/DocumentViews.js @@ -0,0 +1,82 @@ +// @flow +import distanceInWordsToNow from "date-fns/distance_in_words_to_now"; +import { sortBy } from "lodash"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Document from "models/Document"; +import Avatar from "components/Avatar"; +import ListItem from "components/List/Item"; +import PaginatedList from "components/PaginatedList"; +import useStores from "hooks/useStores"; + +type Props = {| + document: Document, + isOpen?: boolean, +|}; + +function DocumentViews({ document, isOpen }: Props) { + const { t } = useTranslation(); + const { views, presence } = useStores(); + + React.useEffect(() => { + views.fetchPage({ documentId: document.id }); + }, [views, document.id]); + + let documentPresence = presence.get(document.id); + documentPresence = documentPresence + ? Array.from(documentPresence.values()) + : []; + + const presentIds = documentPresence.map((p) => p.userId); + const editingIds = documentPresence + .filter((p) => p.isEditing) + .map((p) => p.userId); + + // ensure currently present via websocket are always ordered first + const documentViews = views.inDocument(document.id); + const sortedViews = sortBy(documentViews, (view) => + presentIds.includes(view.user.id) + ); + + const users = React.useMemo(() => sortedViews.map((v) => v.user), [ + sortedViews, + ]); + + return ( + <> + {isOpen && ( + { + const view = documentViews.find((v) => v.user.id === item.id); + const isPresent = presentIds.includes(item.id); + const isEditing = editingIds.includes(item.id); + + const subtitle = isPresent + ? isEditing + ? t("Currently editing") + : t("Currently viewing") + : t("Viewed {{ timeAgo }} ago", { + timeAgo: distanceInWordsToNow( + view ? new Date(view.lastViewedAt) : new Date() + ), + }); + + return ( + } + border={false} + /> + ); + }} + /> + )} + + ); +} + +export default observer(DocumentViews); diff --git a/app/components/Facepile.js b/app/components/Facepile.js index 4d93aba0..cf7a9429 100644 --- a/app/components/Facepile.js +++ b/app/components/Facepile.js @@ -1,45 +1,41 @@ // @flow -import { observer, inject } from "mobx-react"; +import { observer } from "mobx-react"; import * as React from "react"; -import styled, { withTheme } from "styled-components"; +import styled from "styled-components"; import User from "models/User"; import Avatar from "components/Avatar"; import Flex from "components/Flex"; -type Props = { +type Props = {| users: User[], size?: number, overflow: number, - renderAvatar: (user: User) => React.Node, -}; + onClick?: (event: SyntheticEvent<>) => mixed, + renderAvatar?: (user: User) => React.Node, +|}; -@observer -class Facepile extends React.Component { - render() { - const { - users, - overflow, - size = 32, - renderAvatar = renderDefaultAvatar, - ...rest - } = this.props; - - return ( - - {overflow > 0 && ( - - +{overflow} - - )} - {users.map((user) => ( - {renderAvatar(user)} - ))} - - ); - } +function Facepile({ + users, + overflow, + size = 32, + renderAvatar = DefaultAvatar, + ...rest +}: Props) { + return ( + + {overflow > 0 && ( + + +{overflow} + + )} + {users.map((user) => ( + {renderAvatar(user)} + ))} + + ); } -function renderDefaultAvatar(user: User) { +function DefaultAvatar(user: User) { return ; } @@ -73,4 +69,4 @@ const Avatars = styled(Flex)` cursor: pointer; `; -export default inject("views", "presence")(withTheme(Facepile)); +export default observer(Facepile); diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 587d4cab..cafc6ac2 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -8,13 +8,14 @@ type Props = { title: React.Node, subtitle?: React.Node, actions?: React.Node, + border?: boolean, }; -const ListItem = ({ image, title, subtitle, actions }: Props) => { +const ListItem = ({ image, title, subtitle, actions, border }: Props) => { const compact = !subtitle; return ( - + {image && {image}} {title} @@ -27,9 +28,11 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => { const Wrapper = styled.li` display: flex; - padding: 10px 0; + padding: 8px 0; margin: 0; - border-bottom: 1px solid ${(props) => props.theme.divider}; + border-bottom: 1px solid + ${(props) => + props.$border === false ? "transparent" : props.theme.divider}; &:last-child { border-bottom: 0; @@ -59,7 +62,8 @@ const Content = styled(Flex)` const Subtitle = styled.p` margin: 0; font-size: 14px; - color: ${(props) => props.theme.slate}; + color: ${(props) => props.theme.textTertiary}; + margin-top: -2px; `; const Actions = styled.div` diff --git a/app/components/NudeButton.js b/app/components/NudeButton.js index f42fcdf9..5e0027b2 100644 --- a/app/components/NudeButton.js +++ b/app/components/NudeButton.js @@ -3,8 +3,8 @@ import * as React from "react"; import styled from "styled-components"; const Button = styled.button` - width: ${(props) => props.size}px; - height: ${(props) => props.size}px; + width: ${(props) => props.width || props.size}px; + height: ${(props) => props.height || props.size}px; background: none; border-radius: 4px; line-height: 0; diff --git a/app/components/Popover.js b/app/components/Popover.js new file mode 100644 index 00000000..e1af37a7 --- /dev/null +++ b/app/components/Popover.js @@ -0,0 +1,34 @@ +// @flow +import * as React from "react"; +import { Popover as ReakitPopover } from "reakit/Popover"; +import styled from "styled-components"; +import { fadeAndScaleIn } from "shared/styles/animations"; + +type Props = { + children: React.Node, + width?: number, +}; + +function Popover({ children, width = 380, ...rest }: Props) { + return ( + + {children} + + ); +} + +const Contents = styled.div` + animation: ${fadeAndScaleIn} 200ms ease; + transform-origin: 75% 0; + background: ${(props) => props.theme.menuBackground}; + border-radius: 6px; + padding: 12px 24px; + max-height: 50vh; + overflow-y: scroll; + width: ${(props) => props.width}px; + box-shadow: ${(props) => props.theme.menuShadow}; + border: ${(props) => + props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"}; +`; + +export default Popover; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index e7292fea..576aabc5 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -16,7 +16,6 @@ import Badge from "components/Badge"; import Breadcrumb, { Slash } from "components/Breadcrumb"; import Button from "components/Button"; import Collaborators from "components/Collaborators"; -import Fade from "components/Fade"; import Header from "components/Header"; import Tooltip from "components/Tooltip"; import ShareButton from "./ShareButton"; @@ -148,12 +147,10 @@ function DocumentHeader({ actions={ <> {!isPublishing && isSaving && {t("Saving")}…} - - - + {isEditing && !isTemplate && isNew && ( diff --git a/app/scenes/Document/components/ShareButton.js b/app/scenes/Document/components/ShareButton.js index 411c6222..ab691a60 100644 --- a/app/scenes/Document/components/ShareButton.js +++ b/app/scenes/Document/components/ShareButton.js @@ -3,11 +3,10 @@ import { observer } from "mobx-react"; import { GlobeIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; -import { usePopoverState, Popover, PopoverDisclosure } from "reakit/Popover"; -import styled from "styled-components"; -import { fadeAndScaleIn } from "shared/styles/animations"; +import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; import Document from "models/Document"; import Button from "components/Button"; +import Popover from "components/Popover"; import Tooltip from "components/Tooltip"; import SharePopover from "./SharePopover"; import useStores from "hooks/useStores"; @@ -55,28 +54,14 @@ function ShareButton({ document }: Props) { )} - - - + ); } -const Contents = styled.div` - animation: ${fadeAndScaleIn} 200ms ease; - transform-origin: 75% 0; - background: ${(props) => props.theme.menuBackground}; - border-radius: 6px; - padding: 24px 24px 12px; - width: 380px; - box-shadow: ${(props) => props.theme.menuShadow}; - border: ${(props) => - props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"}; -`; - export default observer(ShareButton); diff --git a/app/scenes/Document/components/SharePopover.js b/app/scenes/Document/components/SharePopover.js index 9897d245..4ea2b9a6 100644 --- a/app/scenes/Document/components/SharePopover.js +++ b/app/scenes/Document/components/SharePopover.js @@ -109,7 +109,7 @@ function DocumentShare({ document, share, onSubmit }: Props) { type="text" label={t("Link")} placeholder={`${t("Loading")}…`} - value={share ? share.url : undefined} + value={share ? share.url : ""} labelHidden readOnly /> @@ -126,7 +126,7 @@ function DocumentShare({ document, share, onSubmit }: Props) { const Heading = styled.h2` display: flex; align-items: center; - margin-top: 0; + margin-top: 12px; margin-left: -4px; `; diff --git a/server/api/__snapshots__/users.test.js.snap b/server/api/__snapshots__/users.test.js.snap index adefec1e..626a5eab 100644 --- a/server/api/__snapshots__/users.test.js.snap +++ b/server/api/__snapshots__/users.test.js.snap @@ -23,6 +23,7 @@ Object { "demote": true, "promote": true, "read": true, + "readDetails": true, "suspend": true, "update": false, }, @@ -74,39 +75,7 @@ Object { "demote": true, "promote": true, "read": true, - "suspend": true, - "update": false, - }, - "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", - }, - ], - "status": 200, -} -`; - -exports[`#users.demote should demote an admin to viewer 1`] = ` -Object { - "data": Object { - "avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png", - "createdAt": "2018-01-02T00:00:00.000Z", - "email": "user1@example.com", - "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", - "isAdmin": false, - "isSuspended": false, - "isViewer": true, - "language": "en_US", - "lastActiveAt": null, - "name": "User 1", - }, - "ok": true, - "policies": Array [ - Object { - "abilities": Object { - "activate": true, - "delete": true, - "demote": true, - "promote": true, - "read": true, + "readDetails": true, "suspend": true, "update": false, }, @@ -140,6 +109,41 @@ Object { "demote": true, "promote": true, "read": true, + "readDetails": true, + "suspend": true, + "update": false, + }, + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + }, + ], + "status": 200, +} +`; + +exports[`#users.demote should demote an admin to viewer 1`] = ` +Object { + "data": Object { + "avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png", + "createdAt": "2018-01-02T00:00:00.000Z", + "email": "user1@example.com", + "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": false, + "isSuspended": false, + "isViewer": true, + "language": "en_US", + "lastActiveAt": null, + "name": "User 1", + }, + "ok": true, + "policies": Array [ + Object { + "abilities": Object { + "activate": true, + "delete": true, + "demote": true, + "promote": true, + "read": true, + "readDetails": true, "suspend": true, "update": false, }, @@ -191,6 +195,7 @@ Object { "demote": true, "promote": false, "read": true, + "readDetails": true, "suspend": true, "update": false, }, @@ -251,6 +256,7 @@ Object { "demote": false, "promote": false, "read": true, + "readDetails": true, "suspend": true, "update": false, }, diff --git a/server/api/users.js b/server/api/users.js index 36959fae..b35f4334 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -10,7 +10,7 @@ import { presentUser, presentPolicies } from "../presenters"; import { Op } from "../sequelize"; import pagination from "./middlewares/pagination"; -const { authorize } = policy; +const { can, authorize } = policy; const router = new Router(); router.post("users.list", auth(), pagination(), async (ctx) => { @@ -23,10 +23,10 @@ router.post("users.list", auth(), pagination(), async (ctx) => { if (direction !== "ASC") direction = "DESC"; ctx.assertSort(sort, User); - const user = ctx.state.user; + const actor = ctx.state.user; let where = { - teamId: user.teamId, + teamId: actor.teamId, }; if (!includeSuspended) { @@ -56,10 +56,10 @@ router.post("users.list", auth(), pagination(), async (ctx) => { ctx.body = { pagination: ctx.state.pagination, - data: users.map((listUser) => - presentUser(listUser, { includeDetails: user.isAdmin }) + data: users.map((user) => + presentUser(user, { includeDetails: can(actor, "readDetails", user) }) ), - policies: presentPolicies(user, users), + policies: presentPolicies(actor, users), }; }); @@ -75,11 +75,17 @@ router.post("users.count", auth(), async (ctx) => { }); router.post("users.info", auth(), async (ctx) => { - const { user } = ctx.state; + const { id } = ctx.body; + const actor = ctx.state.user; + + const user = id ? await User.findByPk(id) : actor; + authorize(actor, "read", user); + + const includeDetails = can(actor, "readDetails", user); ctx.body = { - data: presentUser(user), - policies: presentPolicies(user, [user]), + data: presentUser(user, { includeDetails }), + policies: presentPolicies(actor, [user]), }; }); @@ -128,8 +134,10 @@ router.post("users.promote", auth(), async (ctx) => { ip: ctx.request.ip, }); + const includeDetails = can(actor, "readDetails", user); + ctx.body = { - data: presentUser(user, { includeDetails: true }), + data: presentUser(user, { includeDetails }), policies: presentPolicies(actor, [user]), }; }); @@ -159,8 +167,10 @@ router.post("users.demote", auth(), async (ctx) => { ip: ctx.request.ip, }); + const includeDetails = can(actor, "readDetails", user); + ctx.body = { - data: presentUser(user, { includeDetails: true }), + data: presentUser(user, { includeDetails }), policies: presentPolicies(actor, [user]), }; }); @@ -179,8 +189,10 @@ router.post("users.suspend", auth(), async (ctx) => { ip: ctx.request.ip, }); + const includeDetails = can(actor, "readDetails", user); + ctx.body = { - data: presentUser(user, { includeDetails: true }), + data: presentUser(user, { includeDetails }), policies: presentPolicies(actor, [user]), }; }); @@ -205,8 +217,10 @@ router.post("users.activate", auth(), async (ctx) => { ip: ctx.request.ip, }); + const includeDetails = can(actor, "readDetails", user); + ctx.body = { - data: presentUser(user, { includeDetails: true }), + data: presentUser(user, { includeDetails }), policies: presentPolicies(actor, [user]), }; }); diff --git a/server/api/users.test.js b/server/api/users.test.js index 1f062549..d2cd4343 100644 --- a/server/api/users.test.js +++ b/server/api/users.test.js @@ -87,7 +87,7 @@ describe("#users.list", () => { }); describe("#users.info", () => { - it("should return known user", async () => { + it("should return current user with no id", async () => { const user = await buildUser(); const res = await server.post("/api/users.info", { body: { token: user.getJwtToken() }, @@ -97,6 +97,33 @@ describe("#users.info", () => { expect(res.status).toEqual(200); expect(body.data.id).toEqual(user.id); expect(body.data.name).toEqual(user.name); + expect(body.data.email).toEqual(user.email); + }); + + it("should return user with permission", async () => { + const user = await buildUser(); + const another = await buildUser({ teamId: user.teamId }); + const res = await server.post("/api/users.info", { + body: { token: user.getJwtToken(), id: another.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(another.id); + expect(body.data.name).toEqual(another.name); + + // no emails of other users + expect(body.data.email).toEqual(undefined); + }); + + it("should now return user without permission", async () => { + const user = await buildUser(); + const another = await buildUser(); + const res = await server.post("/api/users.info", { + body: { token: user.getJwtToken(), id: another.id }, + }); + + expect(res.status).toEqual(403); }); it("should require authentication", async () => { diff --git a/server/policies/user.js b/server/policies/user.js index c98d1f25..ee818021 100644 --- a/server/policies/user.js +++ b/server/policies/user.js @@ -37,6 +37,12 @@ allow(User, ["activate", "suspend"], User, (actor, user) => { throw new AdminRequiredError(); }); +allow(User, "readDetails", User, (actor, user) => { + if (!user || user.teamId !== actor.teamId) return false; + if (user === actor) return true; + return actor.isAdmin; +}); + allow(User, "promote", User, (actor, user) => { if (!user || user.teamId !== actor.teamId) return false; if (user.isAdmin || user.isSuspended) return false; diff --git a/server/presenters/__snapshots__/user.test.js.snap b/server/presenters/__snapshots__/user.test.js.snap index 2baef57d..455fc1ef 100644 --- a/server/presenters/__snapshots__/user.test.js.snap +++ b/server/presenters/__snapshots__/user.test.js.snap @@ -8,8 +8,6 @@ Object { "isAdmin": undefined, "isSuspended": undefined, "isViewer": undefined, - "language": "en_US", - "lastActiveAt": undefined, "name": "Test User", } `; @@ -22,8 +20,6 @@ Object { "isAdmin": undefined, "isSuspended": undefined, "isViewer": undefined, - "language": "en_US", - "lastActiveAt": undefined, "name": "Test User", } `; diff --git a/server/presenters/user.js b/server/presenters/user.js index 5b8e26ec..5867c332 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -20,16 +20,17 @@ export default (user: User, options: Options = {}): ?UserPresentation => { const userData = {}; userData.id = user.id; userData.createdAt = user.createdAt; - userData.lastActiveAt = user.lastActiveAt; userData.name = user.name; userData.isAdmin = user.isAdmin; userData.isViewer = user.isViewer; userData.isSuspended = user.isSuspended; userData.avatarUrl = user.avatarUrl; - userData.language = user.language || process.env.DEFAULT_LANGUAGE || "en_US"; if (options.includeDetails) { + userData.lastActiveAt = user.lastActiveAt; userData.email = user.email; + userData.language = + user.language || process.env.DEFAULT_LANGUAGE || "en_US"; } return userData; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b953aeca..50a27bef 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -8,6 +8,7 @@ "Drafts": "Drafts", "Templates": "Templates", "Deleted Collection": "Deleted Collection", + "Viewers": "Viewers", "Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection", "Add a description": "Add a description", "Collapse": "Collapse", @@ -29,6 +30,13 @@ "in": "in", "nested document": "nested document", "nested document_plural": "nested documents", + "Viewed by": "Viewed by", + "only you": "only you", + "person": "person", + "people": "people", + "Currently editing": "Currently editing", + "Currently viewing": "Currently viewing", + "Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago", "Insert column after": "Insert column after", "Insert column before": "Insert column before", "Insert row after": "Insert row after", @@ -382,7 +390,6 @@ "Active": "Active", "Admins": "Admins", "Suspended": "Suspended", - "Viewers": "Viewers", "Everyone": "Everyone", "No people to see here.": "No people to see here.", "Profile saved": "Profile saved",