feat: Added ability to click another user to observe them (sync scroll position) (#2858)
* feat: Added ability to click another user to observe them, mainly for fun * language, lower debounce, prevent tooltip from hiding when toggling observation * fix: Don't allow observing self, added banner at top of screen * Dont edit tooltip as it's confusing between our actions and theirs * snapshots
This commit is contained in:
@@ -11,7 +11,7 @@ type Props = {
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
user?: User;
|
user?: User;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
onClick?: () => void;
|
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { observable } from "mobx";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { WithTranslation, withTranslation } from "react-i18next";
|
import { WithTranslation, withTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import User from "~/models/User";
|
import User from "~/models/User";
|
||||||
import UserProfile from "~/scenes/UserProfile";
|
import UserProfile from "~/scenes/UserProfile";
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
@@ -12,8 +12,10 @@ type Props = WithTranslation & {
|
|||||||
user: User;
|
user: User;
|
||||||
isPresent: boolean;
|
isPresent: boolean;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
|
isObserving: boolean;
|
||||||
isCurrentUser: boolean;
|
isCurrentUser: boolean;
|
||||||
profileOnClick: boolean;
|
profileOnClick: boolean;
|
||||||
|
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@@ -30,45 +32,60 @@ class AvatarWithPresence extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { user, isPresent, isEditing, isCurrentUser, t } = this.props;
|
const {
|
||||||
const action = isPresent
|
onClick,
|
||||||
|
user,
|
||||||
|
isPresent,
|
||||||
|
isEditing,
|
||||||
|
isObserving,
|
||||||
|
isCurrentUser,
|
||||||
|
t,
|
||||||
|
} = this.props;
|
||||||
|
const status = isPresent
|
||||||
? isEditing
|
? isEditing
|
||||||
? t("currently editing")
|
? t("currently editing")
|
||||||
: t("currently viewing")
|
: t("currently viewing")
|
||||||
: t("previously edited");
|
: t("previously edited");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltip={
|
tooltip={
|
||||||
<Centered>
|
<Centered>
|
||||||
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
|
||||||
{action && (
|
{status && (
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
{action}
|
{status}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Centered>
|
</Centered>
|
||||||
}
|
}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<AvatarWrapper isPresent={isPresent}>
|
<AvatarWrapper
|
||||||
|
$isPresent={isPresent}
|
||||||
|
$isObserving={isObserving}
|
||||||
|
$color={user.color}
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={user.avatarUrl}
|
src={user.avatarUrl}
|
||||||
onClick={
|
onClick={
|
||||||
this.props.profileOnClick === false
|
this.props.profileOnClick === false
|
||||||
? undefined
|
? onClick
|
||||||
: this.handleOpenProfile
|
: this.handleOpenProfile
|
||||||
}
|
}
|
||||||
size={32}
|
size={32}
|
||||||
/>
|
/>
|
||||||
</AvatarWrapper>
|
</AvatarWrapper>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<UserProfile
|
{this.props.profileOnClick && (
|
||||||
user={user}
|
<UserProfile
|
||||||
isOpen={this.isOpen}
|
user={user}
|
||||||
onRequestClose={this.handleCloseProfile}
|
isOpen={this.isOpen}
|
||||||
/>
|
onRequestClose={this.handleCloseProfile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,9 +95,47 @@ const Centered = styled.div`
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const AvatarWrapper = styled.div<{ isPresent: boolean }>`
|
const AvatarWrapper = styled.div<{
|
||||||
opacity: ${(props) => (props.isPresent ? 1 : 0.5)};
|
$isPresent: boolean;
|
||||||
|
$isObserving: boolean;
|
||||||
|
$color: string;
|
||||||
|
}>`
|
||||||
|
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||||
transition: opacity 250ms ease-in-out;
|
transition: opacity 250ms ease-in-out;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: border-color 100ms ease-in-out;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.$isObserving &&
|
||||||
|
css`
|
||||||
|
border: 2px solid ${props.$color};
|
||||||
|
box-shadow: inset 0 0 0 2px ${props.theme.background};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
top: -1px;
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:after {
|
||||||
|
border: 2px solid ${(props) => props.$color};
|
||||||
|
box-shadow: inset 0 0 0 2px ${(props) => props.theme.background};
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withTranslation()(AvatarWithPresence);
|
export default withTranslation()(AvatarWithPresence);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import DocumentViews from "~/components/DocumentViews";
|
|||||||
import Facepile from "~/components/Facepile";
|
import Facepile from "~/components/Facepile";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import Popover from "~/components/Popover";
|
import Popover from "~/components/Popover";
|
||||||
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
@@ -21,9 +22,10 @@ type Props = {
|
|||||||
function Collaborators(props: Props) {
|
function Collaborators(props: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
|
const team = useCurrentTeam();
|
||||||
const currentUserId = user?.id;
|
const currentUserId = user?.id;
|
||||||
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
|
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
|
||||||
const { users, presence } = useStores();
|
const { users, presence, ui } = useStores();
|
||||||
const { document } = props;
|
const { document } = props;
|
||||||
const documentPresence = presence.get(document.id);
|
const documentPresence = presence.get(document.id);
|
||||||
const documentPresenceArray = documentPresence
|
const documentPresenceArray = documentPresence
|
||||||
@@ -78,17 +80,35 @@ function Collaborators(props: Props) {
|
|||||||
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
<NudeButton width={collaborators.length * 32} height={32} {...props}>
|
||||||
<FacepileHiddenOnMobile
|
<FacepileHiddenOnMobile
|
||||||
users={collaborators}
|
users={collaborators}
|
||||||
renderAvatar={(user) => {
|
renderAvatar={(collaborator) => {
|
||||||
const isPresent = presentIds.includes(user.id);
|
const isPresent = presentIds.includes(collaborator.id);
|
||||||
const isEditing = editingIds.includes(user.id);
|
const isEditing = editingIds.includes(collaborator.id);
|
||||||
|
const isObserving = ui.observingUserId === collaborator.id;
|
||||||
|
const isObservable =
|
||||||
|
team.collaborativeEditing && collaborator.id !== user.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AvatarWithPresence
|
<AvatarWithPresence
|
||||||
key={user.id}
|
key={collaborator.id}
|
||||||
user={user}
|
user={collaborator}
|
||||||
isPresent={isPresent}
|
isPresent={isPresent}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
isCurrentUser={currentUserId === user.id}
|
isObserving={isObserving}
|
||||||
|
isCurrentUser={currentUserId === collaborator.id}
|
||||||
profileOnClick={false}
|
profileOnClick={false}
|
||||||
|
onClick={
|
||||||
|
isObservable
|
||||||
|
? (ev) => {
|
||||||
|
if (isPresent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
ui.setObservingUser(
|
||||||
|
isObserving ? undefined : collaborator.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Props = EditorProps &
|
|||||||
WithTranslation & {
|
WithTranslation & {
|
||||||
onChangeTitle: (text: string) => void;
|
onChangeTitle: (text: string) => void;
|
||||||
title: string;
|
title: string;
|
||||||
|
id: string;
|
||||||
document: Document;
|
document: Document;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
multiplayer?: boolean;
|
multiplayer?: boolean;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import TemplatesMenu from "~/menus/TemplatesMenu";
|
|||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
import { metaDisplay } from "~/utils/keyboard";
|
import { metaDisplay } from "~/utils/keyboard";
|
||||||
import { newDocumentPath, editDocumentUrl } from "~/utils/routeHelpers";
|
import { newDocumentPath, editDocumentUrl } from "~/utils/routeHelpers";
|
||||||
|
import ObservingBanner from "./ObservingBanner";
|
||||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
|
|
||||||
@@ -194,6 +195,7 @@ function DocumentHeader({
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
<ObservingBanner />
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<TocWrapper>
|
<TocWrapper>
|
||||||
<TableOfContentsMenu headings={headings} />
|
<TableOfContentsMenu headings={headings} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
|
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
|
||||||
|
import { throttle } from "lodash";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
@@ -27,7 +28,9 @@ export type ConnectionStatus =
|
|||||||
| "disconnected"
|
| "disconnected"
|
||||||
| void;
|
| void;
|
||||||
|
|
||||||
type AwarenessChangeEvent = { states: { user: { id: string }; cursor: any }[] };
|
type AwarenessChangeEvent = {
|
||||||
|
states: { user: { id: string }; cursor: any; scrollY: number | undefined }[];
|
||||||
|
};
|
||||||
|
|
||||||
type ConnectionStatusEvent = { status: ConnectionStatus };
|
type ConnectionStatusEvent = { status: ConnectionStatus };
|
||||||
|
|
||||||
@@ -68,6 +71,23 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
|||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncScrollPosition = throttle(() => {
|
||||||
|
provider.setAwarenessField(
|
||||||
|
"scrollY",
|
||||||
|
window.scrollY / window.innerHeight
|
||||||
|
);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
const finishObserving = () => {
|
||||||
|
if (ui.observingUserId) {
|
||||||
|
ui.setObservingUser(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("click", finishObserving);
|
||||||
|
window.addEventListener("wheel", finishObserving);
|
||||||
|
window.addEventListener("scroll", syncScrollPosition);
|
||||||
|
|
||||||
provider.on("authenticationFailed", () => {
|
provider.on("authenticationFailed", () => {
|
||||||
showToast(
|
showToast(
|
||||||
t(
|
t(
|
||||||
@@ -78,12 +98,16 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
provider.on("awarenessChange", ({ states }: AwarenessChangeEvent) => {
|
provider.on("awarenessChange", ({ states }: AwarenessChangeEvent) => {
|
||||||
states.forEach(({ user, cursor }) => {
|
states.forEach(({ user, cursor, scrollY }) => {
|
||||||
if (user) {
|
if (user) {
|
||||||
// could know if the user is editing here using `state.cursor` but it
|
|
||||||
// feels distracting in the UI, once multiplayer is on for everyone we
|
|
||||||
// can stop diffentiating
|
|
||||||
presence.touch(documentId, user.id, !!cursor);
|
presence.touch(documentId, user.id, !!cursor);
|
||||||
|
|
||||||
|
if (scrollY !== undefined && user.id === ui.observingUserId) {
|
||||||
|
window.scrollTo({
|
||||||
|
top: scrollY * window.innerHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -128,6 +152,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
|||||||
setRemoteProvider(provider);
|
setRemoteProvider(provider);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener("click", finishObserving);
|
||||||
|
window.removeEventListener("wheel", finishObserving);
|
||||||
|
window.removeEventListener("scroll", syncScrollPosition);
|
||||||
provider?.destroy();
|
provider?.destroy();
|
||||||
localProvider?.destroy();
|
localProvider?.destroy();
|
||||||
setRemoteProvider(null);
|
setRemoteProvider(null);
|
||||||
@@ -228,6 +255,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.forwardRef<typeof MultiplayerEditor, any>(
|
export default React.forwardRef<typeof MultiplayerEditor, Props>(
|
||||||
MultiplayerEditor
|
MultiplayerEditor
|
||||||
);
|
);
|
||||||
|
|||||||
60
app/scenes/Document/components/ObservingBanner.tsx
Normal file
60
app/scenes/Document/components/ObservingBanner.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { m, AnimatePresence } from "framer-motion";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
|
const transition = {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 500,
|
||||||
|
damping: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small banner that is displayed at the top of the screen when the user is
|
||||||
|
* observing another user while editing a document
|
||||||
|
*/
|
||||||
|
function ObservingBanner() {
|
||||||
|
const { ui, users } = useStores();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = ui.observingUserId ? users.get(ui.observingUserId) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Positioner>
|
||||||
|
<AnimatePresence>
|
||||||
|
{user && (
|
||||||
|
<Banner
|
||||||
|
$color={user.color}
|
||||||
|
transition={transition}
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: -5 }}
|
||||||
|
exit={{ opacity: 0, y: -30 }}
|
||||||
|
>
|
||||||
|
{t("Observing {{ userName }}", { userName: user.name })}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Positioner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Positioner = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Banner = styled(m.div)<{ $color: string }>`
|
||||||
|
padding: 6px 6px 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: ${(props) => props.theme.depths.header + 1};
|
||||||
|
color: ${(props) => props.theme.white};
|
||||||
|
background: ${(props) => props.$color};
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(ObservingBanner);
|
||||||
@@ -36,6 +36,9 @@ class UiStore {
|
|||||||
@observable
|
@observable
|
||||||
activeCollectionId: string | undefined;
|
activeCollectionId: string | undefined;
|
||||||
|
|
||||||
|
@observable
|
||||||
|
observingUserId: string | undefined;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
progressBarVisible = false;
|
progressBarVisible = false;
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ class UiStore {
|
|||||||
@action
|
@action
|
||||||
setActiveDocument = (document: Document): void => {
|
setActiveDocument = (document: Document): void => {
|
||||||
this.activeDocumentId = document.id;
|
this.activeDocumentId = document.id;
|
||||||
|
this.observingUserId = undefined;
|
||||||
|
|
||||||
if (document.isActive) {
|
if (document.isActive) {
|
||||||
this.activeCollectionId = document.collectionId;
|
this.activeCollectionId = document.collectionId;
|
||||||
@@ -142,9 +146,15 @@ class UiStore {
|
|||||||
this.activeCollectionId = collection.id;
|
this.activeCollectionId = collection.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
setObservingUser = (userId: string | undefined): void => {
|
||||||
|
this.observingUserId = userId;
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
clearActiveDocument = (): void => {
|
clearActiveDocument = (): void => {
|
||||||
this.activeDocumentId = undefined;
|
this.activeDocumentId = undefined;
|
||||||
|
this.observingUserId = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
@@ -91,12 +91,13 @@ const User = sequelize.define(
|
|||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const color = this.color.replace(/^#/, "");
|
||||||
const initial = this.name ? this.name[0] : "?";
|
const initial = this.name ? this.name[0] : "?";
|
||||||
const hash = crypto
|
const hash = crypto
|
||||||
.createHash("md5")
|
.createHash("md5")
|
||||||
.update(this.email || "")
|
.update(this.email || "")
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
|
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
color() {
|
color() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
exports[`#users.activate should activate a suspended user 1`] = `
|
exports[`#users.activate should activate a suspended user 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||||
"color": "#e600e0",
|
"color": "#e600e0",
|
||||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||||
"email": "user1@example.com",
|
"email": "user1@example.com",
|
||||||
@@ -56,7 +56,7 @@ Object {
|
|||||||
exports[`#users.demote should demote an admin 1`] = `
|
exports[`#users.demote should demote an admin 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||||
"color": "#e600e0",
|
"color": "#e600e0",
|
||||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||||
"email": "user1@example.com",
|
"email": "user1@example.com",
|
||||||
@@ -91,7 +91,7 @@ Object {
|
|||||||
exports[`#users.demote should demote an admin to member 1`] = `
|
exports[`#users.demote should demote an admin to member 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||||
"color": "#e600e0",
|
"color": "#e600e0",
|
||||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||||
"email": "user1@example.com",
|
"email": "user1@example.com",
|
||||||
@@ -126,7 +126,7 @@ Object {
|
|||||||
exports[`#users.demote should demote an admin to viewer 1`] = `
|
exports[`#users.demote should demote an admin to viewer 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||||
"color": "#e600e0",
|
"color": "#e600e0",
|
||||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||||
"email": "user1@example.com",
|
"email": "user1@example.com",
|
||||||
@@ -179,7 +179,7 @@ Object {
|
|||||||
exports[`#users.promote should promote a new admin 1`] = `
|
exports[`#users.promote should promote a new admin 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||||
"color": "#e600e0",
|
"color": "#e600e0",
|
||||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||||
"email": "user1@example.com",
|
"email": "user1@example.com",
|
||||||
@@ -241,7 +241,7 @@ Object {
|
|||||||
exports[`#users.suspend should suspend an user 1`] = `
|
exports[`#users.suspend should suspend an user 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"data": Object {
|
"data": Object {
|
||||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png?c=e600e0",
|
||||||
"color": "#e600e0",
|
"color": "#e600e0",
|
||||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||||
"email": "user1@example.com",
|
"email": "user1@example.com",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import fetch from "fetch-with-proxy";
|
import fetch from "fetch-with-proxy";
|
||||||
|
|
||||||
export const DEFAULT_AVATAR_HOST = "https://tiley.herokuapp.com";
|
export const DEFAULT_AVATAR_HOST =
|
||||||
|
process.env.DEFAULT_AVATAR_HOST || "https://tiley.herokuapp.com";
|
||||||
|
|
||||||
export async function generateAvatarUrl({
|
export async function generateAvatarUrl({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -376,6 +376,7 @@
|
|||||||
"Publish": "Publish",
|
"Publish": "Publish",
|
||||||
"Publishing": "Publishing",
|
"Publishing": "Publishing",
|
||||||
"Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document",
|
"Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document",
|
||||||
|
"Observing {{ userName }}": "Observing {{ userName }}",
|
||||||
"Nested documents": "Nested documents",
|
"Nested documents": "Nested documents",
|
||||||
"Referenced by": "Referenced by",
|
"Referenced by": "Referenced by",
|
||||||
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
|
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
|
||||||
|
|||||||
Reference in New Issue
Block a user