diff options
28 files changed, 1050 insertions, 549 deletions
diff --git a/src/client/DocServer.ts b/src/client/DocServer.ts index 2a7a7c59a..bac324c77 100644 --- a/src/client/DocServer.ts +++ b/src/client/DocServer.ts @@ -31,7 +31,7 @@ export namespace DocServer { export enum WriteMode { Default = 0, //Anything goes - Playground = 1, //Playground (write own/no read) + Playground = 1, //Playground (write own/no read other updates) LiveReadonly = 2,//Live Readonly (no write/read others) LivePlayground = 3,//Live Playground (write own/read others) } @@ -39,9 +39,9 @@ export namespace DocServer { const docsWithUpdates: { [field: string]: Set<Doc> } = {}; export var PlaygroundFields: string[]; - export function setPlaygroundFields(livePlayougroundFields: string[]) { - DocServer.PlaygroundFields = livePlayougroundFields; - livePlayougroundFields.forEach(f => DocServer.setFieldWriteMode(f, DocServer.WriteMode.LivePlayground)); + export function setPlaygroundFields(livePlaygroundFields: string[]) { + DocServer.PlaygroundFields = livePlaygroundFields; + livePlaygroundFields.forEach(f => DocServer.setFieldWriteMode(f, DocServer.WriteMode.LivePlayground)); } export function setFieldWriteMode(field: string, writeMode: WriteMode) { diff --git a/src/client/apis/GoogleAuthenticationManager.tsx b/src/client/apis/GoogleAuthenticationManager.tsx index bf4469aeb..117d1fa1e 100644 --- a/src/client/apis/GoogleAuthenticationManager.tsx +++ b/src/client/apis/GoogleAuthenticationManager.tsx @@ -146,7 +146,7 @@ export default class GoogleAuthenticationManager extends React.Component<{}> { private get dialogueBoxStyle() { const borderColor = this.success === undefined ? "black" : this.success ? "green" : "red"; - return { borderColor, transition: "0.2s borderColor ease" }; + return { borderColor, transition: "0.2s borderColor ease", zIndex: 1002 }; } render() { @@ -155,8 +155,10 @@ export default class GoogleAuthenticationManager extends React.Component<{}> { isDisplayed={this.openState} interactive={true} contents={this.renderPrompt} - overlayDisplayedOpacity={0.9} + // overlayDisplayedOpacity={0.9} dialogueBoxStyle={this.dialogueBoxStyle} + overlayStyle={{ zIndex: 1001 }} + closeOnExternalClick={action(() => this.isOpen = false)} /> ); } diff --git a/src/client/apis/HypothesisAuthenticationManager.tsx b/src/client/apis/HypothesisAuthenticationManager.tsx index a7fcf86a4..c3e8d2fff 100644 --- a/src/client/apis/HypothesisAuthenticationManager.tsx +++ b/src/client/apis/HypothesisAuthenticationManager.tsx @@ -138,7 +138,7 @@ export default class HypothesisAuthenticationManager extends React.Component<{}> private get dialogueBoxStyle() { const borderColor = this.success === undefined ? "black" : this.success ? "green" : "red"; - return { borderColor, transition: "0.2s borderColor ease" }; + return { borderColor, transition: "0.2s borderColor ease", zIndex: 1002 }; } render() { @@ -147,8 +147,10 @@ export default class HypothesisAuthenticationManager extends React.Component<{}> isDisplayed={this.openState} interactive={true} contents={this.renderPrompt} - overlayDisplayedOpacity={0.9} + // overlayDisplayedOpacity={0.9} dialogueBoxStyle={this.dialogueBoxStyle} + overlayStyle={{ zIndex: 1001 }} + closeOnExternalClick={action(() => this.isOpen = false)} /> ); } diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 2d74c462d..35bb80f39 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -550,6 +550,9 @@ export namespace Docs { const dataDoc = MakeDataDelegate(proto, protoProps, data, fieldKey); const viewDoc = Doc.MakeDelegate(dataDoc, delegId); + // so that the list of annotations is already initialised, prevents issues in addonly + dataDoc[fieldKey + "-annotations"] = new List<Doc>(); + proto.links = ComputedField.MakeFunction("links(self)"); viewDoc.author = Doc.CurrentUserEmail; diff --git a/src/client/util/GroupManager.scss b/src/client/util/GroupManager.scss index 544a79e98..51e4fa9e2 100644 --- a/src/client/util/GroupManager.scss +++ b/src/client/util/GroupManager.scss @@ -1,23 +1,47 @@ -@import "../views/globalCssVariables"; - .group-interface { - background-color: whitesmoke !important; - color: grey; - width: 450px; + width: 380px; height: 300px; .dialogue-box { - width: 450; - height: 300; + .group-create { + display: flex; + flex-direction: column; + height: 90%; + justify-content: space-between; + margin-left: 5px; + + input { + border-radius: 5px; + padding: 8px; + min-width: 100%; + border: 1px solid hsl(0, 0%, 80%); + outline: none; + height: 30; + + &:focus { + border: 2.5px solid #2684FF; + } + } + + p { + font-size: 20px; + text-align: left; + color: black; + } + + button { + align-self: flex-end; + } + } } + button { - background: $lighter-alt-accent; + align-self: center; outline: none; border-radius: 5px; border: 0px; - color: #fcfbf7; - text-transform: uppercase; + text-transform: none; letter-spacing: 2px; font-size: 75%; padding: 10px; @@ -36,12 +60,6 @@ border-radius: 10px; } - button { - width: 100%; - align-self: center; - background: $darker-alt-accent; - } - .delete-button { background: rgb(227, 86, 86); } @@ -55,33 +73,33 @@ } .group-heading { - letter-spacing: .5em; - } - - - .group-body { display: flex; - justify-content: space-between; - max-height: 80%; + align-items: center; + margin-bottom: 25px; - .group-create { - display: flex; - flex-direction: column; - flex-basis: 30%; - margin-left: 5px; + p { + font-size: 20px; + text-align: left; + margin-right: 15px; + color: black; + } + } - input { - border-radius: 5px; - border: none; - padding: 4px; - min-width: 100%; - margin: 4px 0 4px 0; - } + .main-container { + display: flex; + flex-direction: column; + .sort-groups { + text-align: left; + margin-left: 5; + cursor: pointer; } - .group-content { - padding-left: 1em; + .group-body { + justify-content: space-between; + height: 220; + background-color: #e8e8e8; + padding-right: 1em; justify-content: space-around; text-align: left; @@ -91,17 +109,18 @@ .group-row { display: flex; - position: relative; margin-bottom: 5px; - min-height: 40px; - border: 1px solid; - border-radius: 10px; + min-height: 30px; align-items: center; .group-name { - position: relative; max-width: 65%; - left: 10; + margin: 0 10; + color: black; + } + + .group-info { + cursor: pointer; } button { @@ -112,10 +131,6 @@ } } - ::placeholder { - color: $intermediate-color; - } - input { border-radius: 5px; border: none; @@ -126,11 +141,4 @@ } } - - h1 { - color: $dark-color; - text-transform: uppercase; - letter-spacing: 2px; - font-size: 120%; - } }
\ No newline at end of file diff --git a/src/client/util/GroupManager.tsx b/src/client/util/GroupManager.tsx index 7c68fc2a0..2e5ecc543 100644 --- a/src/client/util/GroupManager.tsx +++ b/src/client/util/GroupManager.tsx @@ -3,7 +3,7 @@ import { observable, action, runInAction, computed } from "mobx"; import { SelectionManager } from "./SelectionManager"; import MainViewModal from "../views/MainViewModal"; import { observer } from "mobx-react"; -import { Doc, DocListCast, Opt } from "../../fields/Doc"; +import { Doc, DocListCast, Opt, DocListCastAsync } from "../../fields/Doc"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as fa from '@fortawesome/free-solid-svg-icons'; import { library } from "@fortawesome/fontawesome-svg-core"; @@ -12,10 +12,12 @@ import { Utils } from "../../Utils"; import * as RequestPromise from "request-promise"; import Select from 'react-select'; import "./GroupManager.scss"; -import { StrCast } from "../../fields/Types"; +import { StrCast, Cast } from "../../fields/Types"; import GroupMemberView from "./GroupMemberView"; +import { setGroups } from "../../fields/util"; +import { DocServer } from "../DocServer"; -library.add(fa.faWindowClose); +library.add(fa.faPlus, fa.faTimes, fa.faInfoCircle); export interface UserOptions { label: string; @@ -32,26 +34,44 @@ export default class GroupManager extends React.Component<{}> { @observable private users: string[] = []; // list of users populated from the database. @observable private selectedUsers: UserOptions[] | null = null; // list of users selected in the "Select users" dropdown. @observable currentGroup: Opt<Doc>; // the currently selected group. + @observable private createGroupModalOpen: boolean = false; private inputRef: React.RefObject<HTMLInputElement> = React.createRef(); // the ref for the input box. + private currentUserGroups: string[] = []; + @observable private buttonColour: "#979797" | "black" = "#979797"; + @observable private groupSort: "ascending" | "descending" | "none" = "none"; + constructor(props: Readonly<{}>) { super(props); GroupManager.Instance = this; } - // sets up the list of users componentDidMount() { - this.populateUsers().then(resolved => runInAction(() => this.users = resolved)); + this.populateUsers(); } /** - * Fetches the list of users stored on the database and @returns a list of the emails. + * Fetches the list of users stored on the database. */ populateUsers = async () => { - const userList: User[] = JSON.parse(await RequestPromise.get(Utils.prepend("/getUsers"))); - const currentUserIndex = userList.findIndex(user => user.email === Doc.CurrentUserEmail); - currentUserIndex !== -1 && userList.splice(currentUserIndex, 1); - return userList.map(user => user.email); + runInAction(() => this.users = []); + const userList = await RequestPromise.get(Utils.prepend("/getUsers")); + const raw = JSON.parse(userList) as User[]; + const evaluating = raw.map(async user => { + // const isCandidate = user.email !== Doc.CurrentUserEmail; + // if (isCandidate) { + const userDocument = await DocServer.GetRefField(user.userDocumentId); + if (userDocument instanceof Doc) { + const notificationDoc = await Cast(userDocument.rightSidebarCollection, Doc); + runInAction(() => { + if (notificationDoc instanceof Doc) { + this.users.push(user.email); + } + }); + } + // } + }); + return Promise.all(evaluating); } /** @@ -68,6 +88,15 @@ export default class GroupManager extends React.Component<{}> { open = () => { SelectionManager.DeselectAll(); this.isOpen = true; + this.populateUsers(); + DocListCastAsync(this.GroupManagerDoc?.data).then(groups => { + groups?.forEach(group => { + const members: string[] = JSON.parse(StrCast(group.members)); + if (members.includes(Doc.CurrentUserEmail)) this.currentUserGroups.push(StrCast(group.groupName)); + }); + + setGroups(this.currentUserGroups); + }); } /** @@ -77,6 +106,8 @@ export default class GroupManager extends React.Component<{}> { close = () => { this.isOpen = false; this.currentGroup = undefined; + // this.users = []; + this.createGroupModalOpen = false; } /** @@ -89,7 +120,8 @@ export default class GroupManager extends React.Component<{}> { /** * @returns a list of all group documents. */ - private getAllGroups(): Doc[] { + // private ? + getAllGroups(): Doc[] { const groupDoc = this.GroupManagerDoc; return groupDoc ? DocListCast(groupDoc.data) : []; } @@ -98,7 +130,8 @@ export default class GroupManager extends React.Component<{}> { * @returns a group document based on the group name. * @param groupName */ - private getGroup(groupName: string): Doc | undefined { + // private? + getGroup(groupName: string): Doc | undefined { const groupDoc = this.getAllGroups().find(group => group.groupName === groupName); return groupDoc; } @@ -123,6 +156,11 @@ export default class GroupManager extends React.Component<{}> { ); } + getGroupMembers(group: string | Doc): string[] { + if (group instanceof Doc) return JSON.parse(StrCast(group.members)) as string[]; + else return JSON.parse(StrCast(this.getGroup(group)!.members)) as string[]; + } + /** * @returns the members of the admin group. */ @@ -150,6 +188,10 @@ export default class GroupManager extends React.Component<{}> { groupDoc.groupName = groupName; groupDoc.owners = JSON.stringify([Doc.CurrentUserEmail]); groupDoc.members = JSON.stringify(memberEmails); + if (memberEmails.includes(Doc.CurrentUserEmail)) { + this.currentUserGroups.push(groupName); + setGroups(this.currentUserGroups); + } this.addGroup(groupDoc); } @@ -172,8 +214,16 @@ export default class GroupManager extends React.Component<{}> { deleteGroup(group: Doc): boolean { if (group) { if (this.GroupManagerDoc && this.hasEditAccess(group)) { + // TODO look at this later + // SharingManager.Instance.setInternalGroupSharing(group, "Not Shared"); Doc.RemoveDocFromList(this.GroupManagerDoc, "data", group); - SharingManager.Instance.setInternalGroupSharing(group, "Not Shared"); + SharingManager.Instance.removeGroup(group); + const members: string[] = JSON.parse(StrCast(group.members)); + if (members.includes(Doc.CurrentUserEmail)) { + const index = this.currentUserGroups.findIndex(groupName => groupName === group.groupName); + index !== -1 && this.currentUserGroups.splice(index, 1); + setGroups(this.currentUserGroups); + } if (group === this.currentGroup) { runInAction(() => this.currentGroup = undefined); } @@ -193,6 +243,7 @@ export default class GroupManager extends React.Component<{}> { const memberList: string[] = JSON.parse(StrCast(groupDoc.members)); !memberList.includes(email) && memberList.push(email); groupDoc.members = JSON.stringify(memberList); + SharingManager.Instance.shareWithAddedMember(groupDoc, email); } } @@ -205,8 +256,11 @@ export default class GroupManager extends React.Component<{}> { if (this.hasEditAccess(groupDoc)) { const memberList: string[] = JSON.parse(StrCast(groupDoc.members)); const index = memberList.indexOf(email); - index !== -1 && memberList.splice(index, 1); - groupDoc.members = JSON.stringify(memberList); + if (index !== -1) { + const user = memberList.splice(index, 1)[0]; + groupDoc.members = JSON.stringify(memberList); + SharingManager.Instance.removeMember(groupDoc, email); + } } } @@ -243,104 +297,123 @@ export default class GroupManager extends React.Component<{}> { this.createGroupDoc(this.inputRef.current.value, this.selectedUsers?.map(user => user.value)); this.selectedUsers = null; this.inputRef.current.value = ""; + this.buttonColour = "#979797"; } - /** - * A getter that @returns the interface rendered to view an individual group. - */ - private get editingInterface() { - const members: string[] = this.currentGroup ? JSON.parse(StrCast(this.currentGroup.members)) : []; - const options: UserOptions[] = this.currentGroup ? this.options.filter(option => !(JSON.parse(StrCast(this.currentGroup!.members)) as string[]).includes(option.value)) : []; - return (!this.currentGroup ? null : - <div className="editing-interface"> - <div className="editing-header"> - <b>{this.currentGroup.groupName}</b> - <div className={"close-button"} onClick={action(() => this.currentGroup = undefined)}> - <FontAwesomeIcon icon={fa.faWindowClose} size={"lg"} /> + private get groupCreationModal() { + const contents = ( + <div className="group-create"> + <div className="group-heading" style={{ marginBottom: 0 }}> + <p><b>New Group</b></p> + <div className={"close-button"} onClick={action(() => this.createGroupModalOpen = false)}> + <FontAwesomeIcon icon={fa.faTimes} color={"black"} size={"lg"} /> </div> - - {this.hasEditAccess(this.currentGroup) ? - <div className="group-buttons"> - <div className="add-member-dropdown"> - <Select - // isMulti={true} - isSearchable={true} - options={options} - onChange={selectedOption => this.addMemberToGroup(this.currentGroup!, (selectedOption as UserOptions).value)} - placeholder={"Add members"} - value={null} - closeMenuOnSelect={true} - /> - </div> - <button onClick={() => this.deleteGroup(this.currentGroup!)}>Delete group</button> - </div> : - null} - </div> - <div className="editing-contents"> - {members.map(member => ( - <div className="editing-row"> - <div className="user-email"> - {member} - </div> - {this.hasEditAccess(this.currentGroup!) ? <button onClick={() => this.removeMemberFromGroup(this.currentGroup!, member)}> Remove </button> : null} - </div> - ))} </div> + <input + className="group-input" + ref={this.inputRef} + onKeyDown={this.handleKeyDown} + type="text" + placeholder="Group name" + onChange={action(() => this.buttonColour = this.inputRef.current?.value ? "black" : "#979797")} /> + <Select + isMulti={true} + isSearchable={true} + options={this.options} + onChange={this.handleChange} + placeholder={"Select users"} + value={this.selectedUsers} + closeMenuOnSelect={false} + styles={{ + dropdownIndicator: (base, state) => ({ + ...base, + transition: '0.5s all ease', + transform: state.selectProps.menuIsOpen ? 'rotate(180deg)' : undefined + }), + multiValue: (base) => ({ + ...base, + maxWidth: "50%", + + '&:hover': { + maxWidth: "unset" + } + }) + }} + /> + <button onClick={this.createGroup} + style={{ background: this.buttonColour }} + disabled={this.buttonColour === "#979797"} + > + Create + </button> </div> ); + return ( + <MainViewModal + isDisplayed={this.createGroupModalOpen} + interactive={true} + contents={contents} + dialogueBoxStyle={{ width: "90%", height: "70%" }} + closeOnExternalClick={action(() => this.createGroupModalOpen = false)} + /> + ); } /** * A getter that @returns the main interface for the GroupManager. */ private get groupInterface() { + + const sortGroups = (d1: Doc, d2: Doc) => { + const g1 = StrCast(d1.groupName); + const g2 = StrCast(d2.groupName); + + return g1 < g2 ? -1 : g1 === g2 ? 0 : 1; + }; + + let groups = this.getAllGroups(); + groups = this.groupSort === "ascending" ? groups.sort(sortGroups) : this.groupSort === "descending" ? groups.sort(sortGroups).reverse() : groups; + return ( <div className="group-interface"> - {/* <MainViewModal - contents={this.editingInterface} - isDisplayed={this.currentGroup ? true : false} - interactive={true} - dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} - overlayDisplayedOpacity={this.overlayOpacity} - /> */} + {this.groupCreationModal} {this.currentGroup ? <GroupMemberView group={this.currentGroup} - onCloseButtonClick={() => this.currentGroup = undefined} + onCloseButtonClick={action(() => this.currentGroup = undefined)} /> : null} <div className="group-heading"> - <h1>Groups</h1> + <p><b>Manage Groups</b></p> + <button onClick={action(() => this.createGroupModalOpen = true)}> + <FontAwesomeIcon icon={fa.faPlus} size={"sm"} /> Create Group + </button> <div className={"close-button"} onClick={this.close}> - <FontAwesomeIcon icon={fa.faWindowClose} size={"lg"} /> + <FontAwesomeIcon icon={fa.faTimes} color={"black"} size={"lg"} /> </div> </div> - <div className="group-body"> - <div className="group-create"> - <button onClick={this.createGroup}>Create group</button> - <input ref={this.inputRef} onKeyDown={this.handleKeyDown} type="text" placeholder="Group name" /> - <Select - isMulti={true} - isSearchable={true} - options={this.options} - onChange={this.handleChange} - placeholder={"Select users"} - value={this.selectedUsers} - closeMenuOnSelect={false} - /> + <div className="main-container"> + <div + className="sort-groups" + onClick={action(() => this.groupSort = this.groupSort === "ascending" ? "descending" : this.groupSort === "descending" ? "none" : "ascending")}> + Name {this.groupSort === "ascending" ? "↑" : this.groupSort === "descending" ? "↓" : ""} {/* → */} </div> - <div className="group-content"> - {this.getAllGroups().map(group => - <div className="group-row"> - <div className="group-name">{group.groupName}</div> - <button onClick={action(() => this.currentGroup = group)}> - {this.hasEditAccess(group) ? "Edit" : "View"} - </button> + <div className="group-body"> + {groups.map(group => + <div + className="group-row" + key={StrCast(group.groupName)} + > + <div className="group-name" >{group.groupName}</div> + <div className="group-info" onClick={action(() => this.currentGroup = group)}> + <FontAwesomeIcon icon={fa.faInfoCircle} color={"#e8e8e8"} size={"sm"} style={{ backgroundColor: "#1e89d7", borderRadius: "100%", border: "1px solid #1e89d7" }} /> + </div> </div> )} </div> </div> + </div> ); } @@ -351,8 +424,9 @@ export default class GroupManager extends React.Component<{}> { contents={this.groupInterface} isDisplayed={this.isOpen} interactive={true} - dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} - overlayDisplayedOpacity={this.overlayOpacity} + dialogueBoxStyle={{ zIndex: 1002 }} + overlayStyle={{ zIndex: 1001 }} + closeOnExternalClick={this.close} /> ); } diff --git a/src/client/util/GroupMemberView.scss b/src/client/util/GroupMemberView.scss index 7833c485f..c609c5c7b 100644 --- a/src/client/util/GroupMemberView.scss +++ b/src/client/util/GroupMemberView.scss @@ -1,18 +1,17 @@ -@import "../views/globalCssVariables"; - .editing-interface { - background-color: whitesmoke !important; - color: grey; width: 100%; height: 100%; + hr { + margin-top: 20; + } + button { - background: $darker-alt-accent; outline: none; border-radius: 5px; border: 0px; color: #fcfbf7; - text-transform: uppercase; + text-transform: none; letter-spacing: 2px; font-size: 75%; padding: 10px; @@ -32,34 +31,69 @@ .editing-header { margin-bottom: 5; + .group-title { + font-weight: bold; + font-size: 15; + text-align: center; + border: none; + outline: none; + color: black; + margin-top: -5; + height: 20; + text-overflow: ellipsis; + + &:hover { + text-overflow: visible; + overflow-x: auto; + } + } + + .sort-emails { + float: left; + margin: -18 0 0 5; + cursor: pointer; + } + .group-buttons { display: flex; margin-top: 5; + margin-bottom: 25; .add-member-dropdown { - width: 100%; + width: 65%; margin: 0 5; + + input { + height: 30; + } } } } .editing-contents { overflow-y: auto; - // max-height: 67%; - height: 67%; + height: 65%; width: 100%; + color: black; + margin-top: -15px; .editing-row { display: flex; align-items: center; - // border: 1px solid; - // border-radius: 10px; + margin-bottom: 10px; + position: relative; .user-email { - // position: relative; min-width: 65%; word-break: break-all; padding: 0 5; + text-align: left; + } + + .remove-button { + position: absolute; + right: 10; + cursor: pointer; } } } diff --git a/src/client/util/GroupMemberView.tsx b/src/client/util/GroupMemberView.tsx index b2d75158e..f20670c4e 100644 --- a/src/client/util/GroupMemberView.tsx +++ b/src/client/util/GroupMemberView.tsx @@ -4,14 +4,14 @@ import { observer } from "mobx-react"; import GroupManager, { UserOptions } from "./GroupManager"; import { library } from "@fortawesome/fontawesome-svg-core"; import { StrCast } from "../../fields/Types"; -import { action } from "mobx"; +import { action, observable } from "mobx"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as fa from '@fortawesome/free-solid-svg-icons'; import Select from "react-select"; -import { Doc, Opt } from "../../fields/Doc"; +import { Doc } from "../../fields/Doc"; import "./GroupMemberView.scss"; -library.add(fa.faWindowClose); +library.add(fa.faTimes, fa.faTrashAlt); interface GroupMemberViewProps { group: Doc; @@ -21,17 +21,26 @@ interface GroupMemberViewProps { @observer export default class GroupMemberView extends React.Component<GroupMemberViewProps> { + @observable private memberSort: "ascending" | "descending" | "none" = "none"; + private get editingInterface() { - const members: string[] = this.props.group ? JSON.parse(StrCast(this.props.group.members)) : []; + let members: string[] = this.props.group ? JSON.parse(StrCast(this.props.group.members)) : []; + members = this.memberSort === "ascending" ? members.sort() : this.memberSort === "descending" ? members.sort().reverse() : members; + const options: UserOptions[] = this.props.group ? GroupManager.Instance.options.filter(option => !(JSON.parse(StrCast(this.props.group.members)) as string[]).includes(option.value)) : []; + return (!this.props.group ? null : <div className="editing-interface"> <div className="editing-header"> - <b>{this.props.group.groupName}</b> + <input + className="group-title" + value={StrCast(this.props.group.groupName)} + onChange={e => this.props.group.groupName = e.currentTarget.value} + > + </input> <div className={"memberView-closeButton"} onClick={action(this.props.onCloseButtonClick)}> - <FontAwesomeIcon icon={fa.faWindowClose} size={"lg"} /> + <FontAwesomeIcon icon={fa.faTimes} color={"black"} size={"lg"} /> </div> - {GroupManager.Instance.hasEditAccess(this.props.group) ? <div className="group-buttons"> <div className="add-member-dropdown"> @@ -42,19 +51,39 @@ export default class GroupMemberView extends React.Component<GroupMemberViewProp placeholder={"Add members"} value={null} closeMenuOnSelect={true} + styles={{ + dropdownIndicator: (base, state) => ({ + ...base, + transition: '0.5s all ease', + transform: state.selectProps.menuIsOpen ? 'rotate(180deg)' : undefined + }) + }} /> </div> <button onClick={() => GroupManager.Instance.deleteGroup(this.props.group)}>Delete group</button> </div> : null} + <div + className="sort-emails" + onClick={action(() => this.memberSort = this.memberSort === "ascending" ? "descending" : this.memberSort === "descending" ? "none" : "ascending")}> + Emails {this.memberSort === "ascending" ? "↑" : this.memberSort === "descending" ? "↓" : ""} {/* → */} + </div> </div> + <hr /> <div className="editing-contents"> {members.map(member => ( - <div className="editing-row"> + <div + className="editing-row" + key={member} + > <div className="user-email"> {member} </div> - {GroupManager.Instance.hasEditAccess(this.props.group) ? <button onClick={() => GroupManager.Instance.removeMemberFromGroup(this.props.group, member)}> Remove </button> : null} + {GroupManager.Instance.hasEditAccess(this.props.group) ? + <div className={"remove-button"} onClick={() => GroupManager.Instance.removeMemberFromGroup(this.props.group, member)}> + <FontAwesomeIcon icon={fa.faTrashAlt} size={"sm"} /> + </div> + : null} </div> ))} </div> @@ -68,6 +97,8 @@ export default class GroupMemberView extends React.Component<GroupMemberViewProp isDisplayed={true} interactive={true} contents={this.editingInterface} + dialogueBoxStyle={{ width: 400, height: 250 }} + closeOnExternalClick={this.props.onCloseButtonClick} />; } diff --git a/src/client/util/SettingsManager.scss b/src/client/util/SettingsManager.scss index 6d394a38d..c1627e69f 100644 --- a/src/client/util/SettingsManager.scss +++ b/src/client/util/SettingsManager.scss @@ -52,6 +52,7 @@ .settings-body { display: flex; justify-content: space-between; + margin-top: -10; .settings-type { display: flex; @@ -105,6 +106,7 @@ text-transform: uppercase; letter-spacing: 2px; font-size: 120%; + margin-top: 0; } .container { diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx index 981ee698d..90d59aa51 100644 --- a/src/client/util/SettingsManager.tsx +++ b/src/client/util/SettingsManager.tsx @@ -11,10 +11,12 @@ import { Networking } from "../Network"; import { CurrentUserUtils } from "./CurrentUserUtils"; import { Utils } from "../../Utils"; import { Doc } from "../../fields/Doc"; +import GroupManager from "./GroupManager"; import HypothesisAuthenticationManager from "../apis/HypothesisAuthenticationManager"; import GoogleAuthenticationManager from "../apis/GoogleAuthenticationManager"; +import { togglePlaygroundMode } from "../../fields/util"; -library.add(fa.faWindowClose); +library.add(fa.faTimes); @observer export default class SettingsManager extends React.Component<{}> { @@ -25,6 +27,7 @@ export default class SettingsManager extends React.Component<{}> { @observable private settingsContent = "password"; @observable private errorText = ""; @observable private successText = ""; + @observable private playgroundMode = false; private curr_password_ref = React.createRef<HTMLInputElement>(); private new_password_ref = React.createRef<HTMLInputElement>(); private new_confirm_ref = React.createRef<HTMLInputElement>(); @@ -94,21 +97,29 @@ export default class SettingsManager extends React.Component<{}> { HypothesisAuthenticationManager.Instance.fetchAccessToken(true); } + @action + togglePlaygroundMode = () => { + togglePlaygroundMode(); + this.playgroundMode = !this.playgroundMode; + } + private get settingsInterface() { return ( <div className={"settings-interface"}> <div className="settings-heading"> <h1>settings</h1> <div className={"close-button"} onClick={this.close}> - <FontAwesomeIcon icon={fa.faWindowClose} size={"lg"} /> + <FontAwesomeIcon icon={fa.faTimes} color="black" size={"lg"} /> </div> </div> <div className="settings-body"> <div className="settings-type"> <button onClick={this.onClick} value="password">reset password</button> <button onClick={this.noviceToggle} value="data">{`Set ${Doc.UserDoc().noviceMode ? "developer" : "novice"} mode`}</button> + <button onClick={this.togglePlaygroundMode}>{`${this.playgroundMode ? "Disable" : "Enable"} playground mode`}</button> <button onClick={this.googleAuthorize} value="data">{`Link to Google`}</button> <button onClick={this.hypothesisAuthorize} value="data">{`Link to Hypothes.is`}</button> + <button onClick={() => GroupManager.Instance.open()}>Manage groups</button> <button onClick={() => window.location.assign(Utils.prepend("/logout"))}> {CurrentUserUtils.GuestWorkspace ? "Exit" : "Log Out"} </button> @@ -144,8 +155,7 @@ export default class SettingsManager extends React.Component<{}> { contents={this.settingsInterface} isDisplayed={this.isOpen} interactive={true} - dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} - overlayDisplayedOpacity={this.overlayOpacity} + closeOnExternalClick={this.close} /> ); } diff --git a/src/client/util/SharingManager.scss b/src/client/util/SharingManager.scss index fcbc05f8a..130785672 100644 --- a/src/client/util/SharingManager.scss +++ b/src/client/util/SharingManager.scss @@ -1,29 +1,112 @@ -@import "../views/globalCssVariables"; - .sharing-interface { - display: flex; - flex-direction: column; - width: 730px; - - .dialogue-box { - width: 450; - height: 300; - } + width: 600px; + height: 360px; .overlay { transform: translate(-20px, -20px); } + select { + text-align: justify; + text-align-last: end + } + .sharing-contents { display: flex; + flex-direction: column; + + .close-button { + position: absolute; + right: 1em; + top: 1em; + cursor: pointer; + z-index: 999; + } + + .share-setup { + display: flex; + margin-bottom: 20px; + align-items: center; + height: 36; + + .user-search { + width: 90%; + + input { + height: 30; + } + } + + .permissions-select { + z-index: 1; + margin-left: -100; + border: none; + outline: none; + text-align: justify; // for Edge + text-align-last: end; + } + + .share-button { + height: 105%; + margin-left: 2%; + background-color: #979797; + } + } + + .main-container { + display: flex; + margin-top: -10px; + + .individual-container, + .group-container { + width: 50%; + display: flex; + flex-direction: column; + + .user-sort { + text-align: left; + margin-left: 10; + cursor: pointer; + } + + .share-title { + margin-top: 20px; + margin-bottom: 20px; + } + + .groups-list, + .users-list { + font-style: italic; + background: #e8e8e8; + padding-left: 10px; + padding-right: 10px; + overflow-y: scroll; + overflow-x: hidden; + text-align: left; + display: flex; + align-content: center; + align-items: center; + text-align: center; + justify-content: center; + color: black; + height: 250px; + margin: 0 2; + + + .none { + font-style: italic; + + } + } + } + } button { - background: $darker-alt-accent; outline: none; border-radius: 5px; border: 0px; color: #fcfbf7; - text-transform: uppercase; + text-transform: none; letter-spacing: 2px; font-size: 75%; padding: 0 10; @@ -31,37 +114,6 @@ transition: transform 0.2s; height: 25; } - - .individual-container, - .group-container { - width: 50%; - - .share-groups, - .share-individual { - margin-top: 20px; - margin-bottom: 20px; - } - - .groups-list, - .users-list { - font-style: italic; - background: white; - border: 1px solid black; - padding-left: 10px; - padding-right: 10px; - overflow-y: scroll; - overflow-x: hidden; - text-align: left; - display: flex; - align-content: center; - align-items: center; - text-align: center; - justify-content: center; - color: red; - height: 150px; - margin: 0 2; - } - } } .focus-span { @@ -69,11 +121,10 @@ } p { - font-size: 15px; + font-size: 20px; text-align: left; - font-style: italic; - padding: 0; margin: 0 0 20px 0; + color: black; } .hr-substitute { @@ -108,30 +159,41 @@ -moz-user-select: none; -ms-user-select: none; user-select: none; - width: 700px; - min-width: 700px; - max-width: 700px; + width: 100%; text-align: left; font-style: normal; font-size: 14; font-weight: normal; padding: 0; - align-items: baseline; + align-items: center; + + .group-info { + cursor: pointer; + } + + &:hover .padding { + white-space: unset; + } .padding { padding: 0 10px 0 0; color: black; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 40%; } .permissions-dropdown { - outline: none; + border: none; height: 25; + background-color: #e8e8e8; } .edit-actions { display: flex; position: absolute; - right: 51.5%; + right: -10; } } @@ -171,18 +233,4 @@ padding-top: 12px; } } - - .close-button { - border-radius: 5px; - margin-top: 20px; - padding: 10px 0; - background: aliceblue; - transition: 0.5s ease all; - border: 1px solid; - border-color: aliceblue; - } - - .close-button:hover { - border-color: black; - } }
\ No newline at end of file diff --git a/src/client/util/SharingManager.tsx b/src/client/util/SharingManager.tsx index 127ee33ce..d3bc84770 100644 --- a/src/client/util/SharingManager.tsx +++ b/src/client/util/SharingManager.tsx @@ -1,15 +1,13 @@ -import { observable, runInAction, action } from "mobx"; +import { observable, runInAction, action, computed } from "mobx"; import * as React from "react"; import MainViewModal from "../views/MainViewModal"; -import { Doc, Opt, DocCastAsync } from "../../fields/Doc"; +import { Doc, Opt, DocListCastAsync, DataSym, DocListCast } from "../../fields/Doc"; import { DocServer } from "../DocServer"; import { Cast, StrCast } from "../../fields/Types"; import * as RequestPromise from "request-promise"; import { Utils } from "../../Utils"; import "./SharingManager.scss"; -import { Id } from "../../fields/FieldSymbols"; import { observer } from "mobx-react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { library } from '@fortawesome/fontawesome-svg-core'; import * as fa from '@fortawesome/free-solid-svg-icons'; import { DocumentView } from "../views/nodes/DocumentView"; @@ -17,10 +15,14 @@ import { SelectionManager } from "./SelectionManager"; import { DocumentManager } from "./DocumentManager"; import { CollectionView } from "../views/collections/CollectionView"; import { DictationOverlay } from "../views/DictationOverlay"; -import GroupManager from "./GroupManager"; +import GroupManager, { UserOptions } from "./GroupManager"; import GroupMemberView from "./GroupMemberView"; +import Select from "react-select"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { List } from "../../fields/List"; +import { distributeAcls } from "../../fields/util"; -library.add(fa.faCopy); +library.add(fa.faCopy, fa.faTimes); export interface User { email: string; @@ -28,36 +30,24 @@ export interface User { } export enum SharingPermissions { - None = "Not Shared", - View = "Can View", + Edit = "Can Edit", Add = "Can Add", - Edit = "Can Edit" + View = "Can View", + None = "Not Shared" } -const ColorMapping = new Map<string, string>([ - [SharingPermissions.None, "red"], - [SharingPermissions.View, "maroon"], - [SharingPermissions.Add, "blue"], - [SharingPermissions.Edit, "green"] -]); - -const HierarchyMapping = new Map<string, string>([ - [SharingPermissions.None, "0"], - [SharingPermissions.View, "1"], - [SharingPermissions.Add, "2"], - [SharingPermissions.Edit, "3"], - - ["0", SharingPermissions.None], - ["1", SharingPermissions.View], - ["2", SharingPermissions.Add], - ["3", SharingPermissions.Edit] - -]); +interface GroupOptions { + label: string; + options: UserOptions[]; +} const SharingKey = "sharingPermissions"; const PublicKey = "publicLinkPermissions"; const DefaultColor = "black"; +const groupType = "!groupType/"; +const indType = "!indType/"; + interface ValidatedUser { user: User; notificationDoc: Doc; @@ -70,17 +60,20 @@ export default class SharingManager extends React.Component<{}> { public static Instance: SharingManager; @observable private isOpen = false; @observable private users: ValidatedUser[] = []; - @observable private groups: Doc[] = []; @observable private targetDoc: Doc | undefined; @observable private targetDocView: DocumentView | undefined; - @observable private copied = false; + // @observable private copied = false; @observable private dialogueBoxOpacity = 1; @observable private overlayOpacity = 0.4; - @observable private groupToView: Opt<Doc>; + @observable private selectedUsers: UserOptions[] | null = null; + @observable private permissions: SharingPermissions = SharingPermissions.Edit; + @observable private individualSort: "ascending" | "descending" | "none" = "none"; + @observable private groupSort: "ascending" | "descending" | "none" = "none"; - private get linkVisible() { - return this.sharingDoc ? this.sharingDoc[PublicKey] !== SharingPermissions.None : false; - } + + // private get linkVisible() { + // return this.sharingDoc ? this.sharingDoc[PublicKey] !== SharingPermissions.None : false; + // } public open = (target: DocumentView) => { SelectionManager.DeselectAll(); @@ -89,32 +82,21 @@ export default class SharingManager extends React.Component<{}> { this.targetDoc = target.props.Document; DictationOverlay.Instance.hasActiveModal = true; this.isOpen = true; - if (!this.sharingDoc) { - this.sharingDoc = new Doc; - } + this.permissions = SharingPermissions.Edit; })); - runInAction(() => this.groups = GroupManager.Instance.getAllGroupsCopy()); } public close = action(() => { this.isOpen = false; this.users = []; setTimeout(action(() => { - this.copied = false; + // this.copied = false; DictationOverlay.Instance.hasActiveModal = false; this.targetDoc = undefined; }), 500); }); - private get sharingDoc() { - return this.targetDoc ? Cast(this.targetDoc[SharingKey], Doc) as Doc : undefined; - } - - private set sharingDoc(value: Doc | undefined) { - this.targetDoc && (this.targetDoc[SharingKey] = value); - } - constructor(props: {}) { super(props); SharingManager.Instance = this; @@ -142,93 +124,117 @@ export default class SharingManager extends React.Component<{}> { setInternalGroupSharing = (group: Doc, permission: string) => { const members: string[] = JSON.parse(StrCast(group.members)); - const users: ValidatedUser[] = this.users.filter(user => members.includes(user.user.email)); + const users: ValidatedUser[] = this.users.filter(({ user: { email } }) => members.includes(email)); - const sharingDoc = this.sharingDoc!; - if (permission === SharingPermissions.None) { - const metadata = sharingDoc[StrCast(group.groupName)]; - if (metadata) sharingDoc[StrCast(group.groupName)] = undefined; - } - else { - sharingDoc[StrCast(group.groupName)] = permission; - } + const target = this.targetDoc!; + const ACL = `ACL-${StrCast(group.groupName)}`; + // fix this - not needed (here and setinternalsharing and removegroup) + // target[ACL] = permission; + // Doc.GetProto(target)[ACL] = permission; + + distributeAcls(ACL, permission as SharingPermissions, this.targetDoc!); - users.forEach(user => { - this.setInternalSharing(user, permission, group); + group.docsShared ? DocListCastAsync(group.docsShared).then(resolved => Doc.IndexOf(target, resolved!) === -1 && (group.docsShared as List<Doc>).push(target)) : group.docsShared = new List<Doc>([target]); + + users.forEach(({ notificationDoc }) => { + DocListCastAsync(notificationDoc[storage]).then(resolved => { + if (permission !== SharingPermissions.None) Doc.IndexOf(target, resolved!) === -1 && Doc.AddDocToList(notificationDoc, storage, target); + else Doc.IndexOf(target, resolved!) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, target); + }); }); } - setInternalSharing = async (recipient: ValidatedUser, state: string, group: Opt<Doc>) => { - const { user, notificationDoc } = recipient; - const target = this.targetDoc!; - const manager = this.sharingDoc!; - const key = user.userDocumentId; - - let metadata = await DocCastAsync(manager[key]); - const permissions: { [key: string]: number } = metadata?.permissions ? JSON.parse(StrCast(metadata.permissions)) : {}; - permissions[StrCast(group ? group.groupName : Doc.CurrentUserEmail)] = parseInt(HierarchyMapping.get(state)!); - const max = Math.max(...Object.values(permissions)); - - // let max = 0; - // const keys: string[] = []; - // for (const [key, value] of Object.entries(permissions)) { - // if (value === max && max !== 0) { - // keys.push(key); - // } - // else if (value > max) { - // keys.splice(0, keys.length); - // keys.push(key); - // max = value; - // } - // } - - switch (max) { - case 0: - if (metadata) { - const sharedAlias = (await DocCastAsync(metadata.sharedAlias))!; - Doc.RemoveDocFromList(notificationDoc, storage, sharedAlias); - manager[key] = undefined; - } - break; - - case 1: case 2: case 3: - if (!metadata) { - metadata = new Doc; - const sharedAlias = Doc.MakeAlias(target); - Doc.AddDocToList(notificationDoc, storage, sharedAlias); - metadata.sharedAlias = sharedAlias; - manager[key] = metadata; - } - metadata.permissions = JSON.stringify(permissions); - // metadata.usersShared = JSON.stringify(keys); - break; - } + shareWithAddedMember = (group: Doc, emailId: string) => { + const user: ValidatedUser = this.users.find(({ user: { email } }) => email === emailId)!; - if (metadata) metadata.maxPermission = HierarchyMapping.get(`${max}`); + if (group.docsShared) { + DocListCastAsync(group.docsShared).then(docsShared => { + docsShared?.forEach(doc => { + DocListCastAsync(user.notificationDoc[storage]).then(resolved => Doc.IndexOf(doc, resolved!) === -1 && Doc.AddDocToList(user.notificationDoc, storage, doc)); + }); + }); + } } - private setExternalSharing = (state: string) => { - const sharingDoc = this.sharingDoc; - if (!sharingDoc) { - return; + removeMember = (group: Doc, emailId: string) => { + const user: ValidatedUser = this.users.find(({ user: { email } }) => email === emailId)!; + + if (group.docsShared) { + DocListCastAsync(group.docsShared).then(docsShared => { + docsShared?.forEach(doc => { + DocListCastAsync(user.notificationDoc[storage]).then(resolved => Doc.IndexOf(doc, resolved!) !== -1 && Doc.RemoveDocFromList(user.notificationDoc, storage, doc)); + }); + }); } - sharingDoc[PublicKey] = state; } - private get sharingUrl() { - if (!this.targetDoc) { - return undefined; + removeGroup = (group: Doc) => { + if (group.docsShared) { + DocListCastAsync(group.docsShared).then(resolved => { + resolved?.forEach(doc => { + const ACL = `ACL-${StrCast(group.groupName)}`; + // doc[ACL] = doc[DataSym][ACL] = "Not Shared"; + + distributeAcls(ACL, SharingPermissions.None, doc); + + const members: string[] = JSON.parse(StrCast(group.members)); + const users: ValidatedUser[] = this.users.filter(({ user: { email } }) => members.includes(email)); + + users.forEach(({ notificationDoc }) => Doc.RemoveDocFromList(notificationDoc, storage, doc)); + }); + + }); } - const baseUrl = Utils.prepend("/doc/" + this.targetDoc[Id]); - return `${baseUrl}?sharing=true`; } - copy = action(() => { - if (this.sharingUrl) { - Utils.CopyText(this.sharingUrl); - this.copied = true; + // @action + setInternalSharing = (recipient: ValidatedUser, permission: string) => { + const { user, notificationDoc } = recipient; + const target = this.targetDoc!; + const key = user.email.replace('.', '_'); + + const ACL = `ACL-${key}`; + + // target[ACL] = permission; + // Doc.GetProto(target)[ACL] = permission; + + distributeAcls(ACL, permission as SharingPermissions, this.targetDoc!); + + if (permission !== SharingPermissions.None) { + DocListCastAsync(notificationDoc[storage]).then(resolved => { + Doc.IndexOf(target, resolved!) === -1 && Doc.AddDocToList(notificationDoc, storage, target); + }); } - }); + else { + DocListCastAsync(notificationDoc[storage]).then(resolved => { + Doc.IndexOf(target, resolved!) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, target); + }); + } + } + + + // private setExternalSharing = (permission: string) => { + // const sharingDoc = this.sharingDoc; + // if (!sharingDoc) { + // return; + // } + // sharingDoc[PublicKey] = permission; + // } + + // private get sharingUrl() { + // if (!this.targetDoc) { + // return undefined; + // } + // const baseUrl = Utils.prepend("/doc/" + this.targetDoc[Id]); + // return `${baseUrl}?sharing=true`; + // } + + // copy = action(() => { + // if (this.sharingUrl) { + // Utils.CopyText(this.sharingUrl); + // this.copied = true; + // } + // }); private get sharingOptions() { return Object.values(SharingPermissions).map(permission => { @@ -270,33 +276,141 @@ export default class SharingManager extends React.Component<{}> { ); } - private computePermissions = (userKey: string) => { - const sharingDoc = this.sharingDoc; - if (!sharingDoc) { - return SharingPermissions.None; - } - const metadata = sharingDoc[userKey] as Doc | string; - if (!metadata) { - return SharingPermissions.None; - } - return StrCast(metadata instanceof Doc ? metadata.maxPermission : metadata, SharingPermissions.None); + @action + handleUsersChange = (selectedOptions: any) => { + this.selectedUsers = selectedOptions as UserOptions[]; + } + + @action + handlePermissionsChange = (event: React.ChangeEvent<HTMLSelectElement>) => { + this.permissions = event.currentTarget.value as SharingPermissions; + } + + @action + share = () => { + this.selectedUsers?.forEach(user => { + if (user.value.includes(indType)) { + this.setInternalSharing(this.users.find(u => u.user.email === user.label)!, this.permissions); + } + else { + this.setInternalGroupSharing(GroupManager.Instance.getGroup(user.label)!, this.permissions); + } + }); + this.selectedUsers = null; + } + + sortUsers = (u1: ValidatedUser, u2: ValidatedUser) => { + const { email: e1 } = u1.user; + const { email: e2 } = u2.user; + return e1 < e2 ? -1 : e1 === e2 ? 0 : 1; + } + + sortGroups = (group1: Doc, group2: Doc) => { + const g1 = StrCast(group1.groupName); + const g2 = StrCast(group2.groupName); + return g1 < g2 ? -1 : g1 === g2 ? 0 : 1; } private get sharingInterface() { - const existOtherUsers = this.users.length > 0; - const existGroups = this.groups.length > 0; + const groupList = GroupManager.Instance?.getAllGroups() || []; + + const sortedUsers = this.users.sort(this.sortUsers) + .map(({ user: { email } }) => ({ label: email, value: indType + email })); + const sortedGroups = groupList.sort(this.sortGroups) + .map(({ groupName }) => ({ label: StrCast(groupName), value: groupType + StrCast(groupName) })); + + const options: GroupOptions[] = GroupManager.Instance ? + [ + { + label: 'Individuals', + options: sortedUsers + }, + { + label: 'Groups', + options: sortedGroups + } + ] + : []; + + const users = this.individualSort === "ascending" ? this.users.sort(this.sortUsers) : this.individualSort === "descending" ? this.users.sort(this.sortUsers).reverse() : this.users; + const groups = this.groupSort === "ascending" ? groupList.sort(this.sortGroups) : this.groupSort === "descending" ? groupList.sort(this.sortGroups).reverse() : groupList; + + const userListContents: (JSX.Element | null)[] = users.map(({ user, notificationDoc }) => { + const userKey = user.email.replace('.', '_'); + const permissions = StrCast(this.targetDoc?.[`ACL-${userKey}`], SharingPermissions.None); + + return permissions === SharingPermissions.None || user.email === this.targetDoc?.author ? null : ( + <div + key={userKey} + className={"container"} + > + <span className={"padding"}>{user.email}</span> + <div className="edit-actions"> + <select + className={"permissions-dropdown"} + value={permissions} + onChange={e => this.setInternalSharing({ user, notificationDoc }, e.currentTarget.value)} + > + {this.sharingOptions} + </select> + </div> + </div> + ); + }); - // const manager = this.sharingDoc!; + userListContents.unshift( + ( + <div + key={"owner"} + className={"container"} + > + <span className={"padding"}>{this.targetDoc?.author}</span> + <div className="edit-actions"> + <div className={"permissions-dropdown"}> + Owner + </div> + </div> + </div> + ) + ); + + const groupListContents = groups.map(group => { + const permissions = StrCast(this.targetDoc?.[`ACL-${StrCast(group.groupName)}`], SharingPermissions.None); + + return permissions === SharingPermissions.None ? null : ( + <div + key={StrCast(group.groupName)} + className={"container"} + > + <div className={"padding"}>{group.groupName}</div> + <div className="group-info" onClick={action(() => GroupManager.Instance.currentGroup = group)}> + <FontAwesomeIcon icon={fa.faInfoCircle} color={"#e8e8e8"} size={"sm"} style={{ backgroundColor: "#1e89d7", borderRadius: "100%", border: "1px solid #1e89d7" }} /> + </div> + <div className="edit-actions"> + <select + className={"permissions-dropdown"} + value={permissions} + onChange={e => this.setInternalGroupSharing(group, e.currentTarget.value)} + > + {this.sharingOptions} + </select> + </div> + </div> + ); + }); + + const displayUserList = !userListContents?.every(user => user === null); + const displayGroupList = !groupListContents?.every(group => group === null); return ( <div className={"sharing-interface"}> - {this.groupToView ? + {GroupManager.Instance?.currentGroup ? <GroupMemberView - group={this.groupToView} - onCloseButtonClick={action(() => this.groupToView = undefined)} + group={GroupManager.Instance.currentGroup} + onCloseButtonClick={action(() => GroupManager.Instance.currentGroup = undefined)} /> : null} - <p className={"share-link"}>Manage the public link to {this.focusOn("this document...")}</p> + {/* <p className={"share-link"}>Manage the public link to {this.focusOn("this document...")}</p> {!this.linkVisible ? (null) : <div className={"link-container"}> <div className={"link-box"} onClick={this.copy}>{this.sharingUrl}</div> @@ -325,86 +439,81 @@ export default class SharingManager extends React.Component<{}> { {this.sharingOptions} </select> </div> - <div className={"hr-substitute"} /> + <div className={"hr-substitute"} /> */} <div className="sharing-contents"> - <div className={"individual-container"}> - <p className={"share-individual"}>Privately share {this.focusOn("this document")} with an individual...</p> - <div className={"users-list"} style={{ display: existOtherUsers ? "block" : "flex", minHeight: existOtherUsers ? undefined : 150 }}>{/*200*/} - {!existOtherUsers ? "There are no other users in your database." : - this.users.map(({ user, notificationDoc }) => { // can't use async here - const userKey = user.userDocumentId; - const permissions = this.computePermissions(userKey); - const color = ColorMapping.get(permissions); - - // console.log(manager); - // const metadata = manager[userKey] as Doc; - // const usersShared = StrCast(metadata?.usersShared, ""); - // console.log(usersShared) - - - return ( + <p className={"share-title"}><b>Share </b>{this.focusOn(StrCast(this.targetDoc?.title, "this document"))}</p> + <div className={"close-button"} onClick={this.close}> + <FontAwesomeIcon icon={fa.faTimes} color={"black"} size={"lg"} /> + </div> + {this.targetDoc?.author !== Doc.CurrentUserEmail ? null + : + <div className="share-setup"> + <Select + className={"user-search"} + placeholder={"Enter user or group name..."} + isMulti + closeMenuOnSelect={false} + options={options} + onChange={this.handleUsersChange} + value={this.selectedUsers} + /> + <select className="permissions-select" onChange={this.handlePermissionsChange}> + {this.sharingOptions} + </select> + <button className="share-button" onClick={this.share}> + Share + </button> + </div> + } + <div className="main-container"> + <div className={"individual-container"}> + <div + className="user-sort" + onClick={action(() => this.individualSort = this.individualSort === "ascending" ? "descending" : this.individualSort === "descending" ? "none" : "ascending")}> + Individuals {this.individualSort === "ascending" ? "↑" : this.individualSort === "descending" ? "↓" : ""} {/* → */} + </div> + <div className={"users-list"} style={{ display: !displayUserList ? "flex" : "block" }}>{/*200*/} + { + !displayUserList ? <div - key={userKey} - className={"container"} + className={"none"} > - <span className={"padding"}>{user.email}</span> - {/* <div className={"shared-by"}>{usersShared}</div> */} - <div className="edit-actions"> - <select - className={"permissions-dropdown"} - value={permissions} - style={{ color, borderColor: color }} - onChange={e => this.setInternalSharing({ user, notificationDoc }, e.currentTarget.value, undefined)} - > - {this.sharingOptions} - </select> - </div> + There are no users this document has been shared with. </div> - ); - }) - } + : + userListContents + } + </div> </div> - </div> - <div className={"group-container"}> - <p className={"share-groups"}>Privately share {this.focusOn("this document")} with a group...</p> - <div className={"groups-list"} style={{ display: existGroups ? "block" : "flex", minHeight: existOtherUsers ? undefined : 150 }}>{/*200*/} - {!existGroups ? "There are no groups in your database." : - this.groups.map(group => { - const permissions = this.computePermissions(StrCast(group.groupName)); - const color = ColorMapping.get(permissions); - return ( + <div className={"group-container"}> + <div + className="user-sort" + onClick={action(() => this.groupSort = this.groupSort === "ascending" ? "descending" : this.groupSort === "descending" ? "none" : "ascending")}> + Groups {this.groupSort === "ascending" ? "↑" : this.groupSort === "descending" ? "↓" : ""} {/* → */} + + </div> + <div className={"groups-list"} style={{ display: !displayGroupList ? "flex" : "block" }}>{/*200*/} + { + !displayGroupList ? <div - key={StrCast(group.groupName)} - className={"container"} + className={"none"} > - <span className={"padding"}>{group.groupName}</span> - <div className="edit-actions"> - <select - className={"permissions-dropdown"} - value={permissions} - style={{ color, borderColor: color }} - onChange={e => this.setInternalGroupSharing(group, e.currentTarget.value)} - > - {this.sharingOptions} - </select> - <button onClick={action(() => this.groupToView = group)}>Edit</button> + There are no groups this document has been shared with. </div> - </div> - ); - }) - - } + : + groupListContents + } + </div> </div> </div> + </div> - <div className={"close-button"} onClick={this.close}>Done</div> </div> ); } render() { - // console.log(this.sharingDoc); return ( <MainViewModal contents={this.sharingInterface} @@ -412,6 +521,7 @@ export default class SharingManager extends React.Component<{}> { interactive={true} dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} overlayDisplayedOpacity={this.overlayOpacity} + closeOnExternalClick={this.close} /> ); } diff --git a/src/client/views/DictationOverlay.tsx b/src/client/views/DictationOverlay.tsx index 65770c0bb..9ed14509f 100644 --- a/src/client/views/DictationOverlay.tsx +++ b/src/client/views/DictationOverlay.tsx @@ -66,6 +66,7 @@ export class DictationOverlay extends React.Component { interactive={false} dialogueBoxStyle={dialogueBoxStyle} overlayStyle={overlayStyle} + closeOnExternalClick={this.initiateDictationFade} />); } }
\ No newline at end of file diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index 9b9a28f0f..655be80ef 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -1,4 +1,4 @@ -import { Doc, Opt, DataSym, DocListCast, AclSym, AclReadonly, AclAddonly } from '../../fields/Doc'; +import { Doc, Opt, DataSym, AclReadonly, AclAddonly, AclPrivate, AclEdit, AclSym, DocListCastAsync, DocListCast } from '../../fields/Doc'; import { Touchable } from './Touchable'; import { computed, action, observable } from 'mobx'; import { Cast, BoolCast, ScriptCast } from '../../fields/Types'; @@ -7,6 +7,8 @@ import { InteractionUtils } from '../util/InteractionUtils'; import { List } from '../../fields/List'; import { DateField } from '../../fields/DateField'; import { ScriptField } from '../../fields/ScriptField'; +import { GetEffectiveAcl, getPlaygroundMode } from '../../fields/util'; +import { SharingPermissions } from '../util/SharingManager'; /// DocComponent returns a generic React base class used by views that don't have 'fieldKey' props (e.g.,CollectionFreeFormDocumentView, DocumentView) @@ -91,6 +93,13 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T // key where data is stored @computed get fieldKey() { return this.props.fieldKey; } + private AclMap = new Map<symbol, string>([ + [AclPrivate, SharingPermissions.None], + [AclReadonly, SharingPermissions.View], + [AclAddonly, SharingPermissions.Add], + [AclEdit, SharingPermissions.Edit] + ]); + lookupField = (field: string) => ScriptCast((this.layoutDoc as any).lookupField)?.script.run({ self: this.layoutDoc, data: this.rootDoc, field: field }).result; styleFromLayoutString = (scale: number) => { @@ -117,11 +126,13 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T docs.map(doc => doc.annotationOn = undefined); const targetDataDoc = this.dataDoc; const value = DocListCast(targetDataDoc[this.annotationKey]); - const result = value.filter(v => !docs.includes(v)); - if (result.length !== value.length) { - targetDataDoc[this.annotationKey] = new List<Doc>(result); + const toRemove = value.filter(v => docs.includes(v)); + // can't assign new List<Doc>(result) to this because you can't assign new values in addonly + if (toRemove.length !== 0) { + toRemove.forEach(doc => Doc.RemoveDocFromList(targetDataDoc, this.annotationKey, doc)); return true; } + return false; } // if the moved document is already in this overlay collection nothing needs to be done. @@ -137,15 +148,30 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T const targetDataDoc = this.props.Document[DataSym]; const docList = DocListCast(targetDataDoc[this.annotationKey]); const added = docs.filter(d => !docList.includes(d)); + const effectiveAcl = GetEffectiveAcl(this.dataDoc); + if (added.length) { - if (this.dataDoc[AclSym] === AclReadonly) { + if (effectiveAcl === AclReadonly && !getPlaygroundMode()) { return false; - } else if (this.dataDoc[AclSym] === AclAddonly) { - added.map(doc => Doc.AddDocToList(targetDataDoc, this.annotationKey, doc)); - } else { - added.map(doc => doc.context = this.props.Document); - targetDataDoc[this.annotationKey] = new List<Doc>([...docList, ...added]); - targetDataDoc[this.annotationKey + "-lastModified"] = new DateField(new Date(Date.now())); + } + else { + if (this.props.Document[AclSym]) { + added.forEach(d => { + const dataDoc = d[DataSym]; + dataDoc[AclSym] = d[AclSym] = this.props.Document[AclSym]; + for (const [key, value] of Object.entries(this.props.Document[AclSym])) { + dataDoc[key] = d[key] = this.AclMap.get(value); + } + }); + } + if (effectiveAcl === AclAddonly) { + added.map(doc => console.log(Doc.AddDocToList(targetDataDoc, this.annotationKey, doc))); + } + else { + added.map(doc => doc.context = this.props.Document); + targetDataDoc[this.annotationKey] = new List<Doc>([...docList, ...added]); + targetDataDoc[this.annotationKey + "-lastModified"] = new DateField(new Date(Date.now())); + } } } return true; diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index 8c233489e..4dfa7aec8 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -22,6 +22,7 @@ import { DocumentView } from "./nodes/DocumentView"; import { DocumentLinksButton } from "./nodes/DocumentLinksButton"; import PDFMenu from "./pdf/PDFMenu"; import { ContextMenu } from "./ContextMenu"; +import GroupManager from "../util/GroupManager"; import { CollectionFreeFormViewChrome } from "./collections/CollectionMenu"; const modifiers = ["control", "meta", "shift", "alt"]; @@ -108,6 +109,7 @@ export default class KeyManager { GoogleAuthenticationManager.Instance.cancel(); HypothesisAuthenticationManager.Instance.cancel(); SharingManager.Instance.close(); + GroupManager.Instance.close(); CollectionFreeFormViewChrome.Instance.clearKeep(); break; case "delete": diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 46eeac77a..aadfdef21 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -90,7 +90,7 @@ export class MainView extends React.Component { public isPointerDown = false; componentDidMount() { - DocServer.setPlaygroundFields(["dataTransition", "_viewTransition", "_panX", "_panY", "_viewScale", "_viewType"]); // can play with these fields on someone else's + DocServer.setPlaygroundFields(["dataTransition", "_viewTransition", "_panX", "_panY", "_viewScale", "_viewType", "_chromeStatus"]); // can play with these fields on someone else's const tag = document.createElement('script'); @@ -457,9 +457,6 @@ export class MainView extends React.Component { <button className="mainView-settings" key="settings" onClick={() => SettingsManager.Instance.open()}> <FontAwesomeIcon icon="cog" size="lg" /> </button> - <button className="mainView-settings" key="groups" onClick={() => GroupManager.Instance.open()}> - <FontAwesomeIcon icon="columns" size="lg" /> - </button> </div> </div> {this.docButtons} diff --git a/src/client/views/MainViewModal.scss b/src/client/views/MainViewModal.scss index f5a9ee76c..812fe540b 100644 --- a/src/client/views/MainViewModal.scss +++ b/src/client/views/MainViewModal.scss @@ -6,9 +6,10 @@ align-self: center; align-content: center; padding: 20px; - background: gainsboro; + // background: gainsboro; + background: white; border-radius: 10px; - border: 3px solid black; + border: 0.5px solid black; box-shadow: #00000044 5px 5px 10px; transform: translate(-50%, -50%); top: 50%; diff --git a/src/client/views/MainViewModal.tsx b/src/client/views/MainViewModal.tsx index a7bd5882d..249715511 100644 --- a/src/client/views/MainViewModal.tsx +++ b/src/client/views/MainViewModal.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import "./MainViewModal.scss"; +import { observer } from 'mobx-react'; export interface MainViewOverlayProps { isDisplayed: boolean; @@ -9,8 +10,10 @@ export interface MainViewOverlayProps { overlayStyle?: React.CSSProperties; dialogueBoxDisplayedOpacity?: number; overlayDisplayedOpacity?: number; + closeOnExternalClick?: () => void; } +@observer export default class MainViewModal extends React.Component<MainViewOverlayProps> { render() { @@ -18,11 +21,10 @@ export default class MainViewModal extends React.Component<MainViewOverlayProps> const dialogueOpacity = p.dialogueBoxDisplayedOpacity || 1; const overlayOpacity = p.overlayDisplayedOpacity || 0.4; return !p.isDisplayed ? (null) : ( - <div style={{ pointerEvents: p.isDisplayed ? p.interactive ? "all" : "none" : "none" }}> + <div style={{ pointerEvents: p.isDisplayed && p.interactive ? "all" : "none" }}> <div className={"dialogue-box"} style={{ - backgroundColor: "gainsboro", borderColor: "black", ...(p.dialogueBoxStyle || {}), opacity: p.isDisplayed ? dialogueOpacity : 0 @@ -30,6 +32,7 @@ export default class MainViewModal extends React.Component<MainViewOverlayProps> >{p.contents}</div> <div className={"overlay"} + onClick={this.props?.closeOnExternalClick} style={{ backgroundColor: "gainsboro", ...(p.overlayStyle || {}), diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 3794088d4..8a3c2144e 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -91,6 +91,11 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: // to its children which may be templates. // If 'annotationField' is specified, then all children exist on that field of the extension document, otherwise, they exist directly on the data document under 'fieldKey' @computed get dataField() { + // sets the dataDoc's data field to an empty list if the data field is undefined - prevents issues with addonly + // setTimeout changes it outside of the @computed section + setTimeout(() => { + if (!this.dataDoc[this.props.annotationsKey || this.props.fieldKey]) this.dataDoc[this.props.annotationsKey || this.props.fieldKey] = new List<Doc>(); + }, 1000); return this.dataDoc[this.props.annotationsKey || this.props.fieldKey]; } @@ -418,4 +423,5 @@ import { FormattedTextBox, GoogleRef } from "../nodes/formattedText/FormattedTex import { CollectionView } from "./CollectionView"; import { SelectionManager } from "../../util/SelectionManager"; import { OverlayView } from "../OverlayView"; +import { setTimeout } from "timers"; diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index c1da23470..5cef6c44e 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -8,7 +8,7 @@ import * as React from 'react'; import Lightbox from 'react-image-lightbox-with-rotate'; import 'react-image-lightbox-with-rotate/style.css'; // This only needs to be imported once in your app import { DateField } from '../../../fields/DateField'; -import { AclAddonly, AclReadonly, AclSym, DataSym, Doc, DocListCast, Field, Opt } from '../../../fields/Doc'; +import { AclAddonly, AclReadonly, DataSym, Doc, DocListCast, Field, Opt, AclEdit, AclSym, AclPrivate } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; @@ -17,7 +17,7 @@ import { listSpec } from '../../../fields/Schema'; import { ComputedField, ScriptField } from '../../../fields/ScriptField'; import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; -import { TraceMobx } from '../../../fields/util'; +import { TraceMobx, GetEffectiveAcl, getPlaygroundMode, distributeAcls } from '../../../fields/util'; import { emptyFunction, emptyPath, returnEmptyFilter, returnFalse, returnOne, returnZero, setupMoveUpEvents, Utils } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; @@ -48,6 +48,7 @@ import { CollectionTimeView } from './CollectionTimeView'; import { CollectionTreeView } from "./CollectionTreeView"; import './CollectionView.scss'; import CollectionMenu from './CollectionMenu'; +import { SharingPermissions } from '../../util/SharingManager'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -106,6 +107,13 @@ export class CollectionView extends Touchable<FieldViewProps & CollectionViewCus protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; + private AclMap = new Map<symbol, string>([ + [AclPrivate, SharingPermissions.None], + [AclReadonly, SharingPermissions.View], + [AclAddonly, SharingPermissions.Add], + [AclEdit, SharingPermissions.Edit] + ]); + get collectionViewType(): CollectionViewType | undefined { const viewField = StrCast(this.props.Document._viewType); if (CollectionView._safeMode) { @@ -128,36 +136,54 @@ export class CollectionView extends Touchable<FieldViewProps & CollectionViewCus if (this.props.filterAddDocument?.(doc) === false) { return false; } + const docs = doc instanceof Doc ? [doc] : doc; const targetDataDoc = this.props.Document[DataSym]; const docList = DocListCast(targetDataDoc[this.props.fieldKey]); const added = docs.filter(d => !docList.includes(d)); + const effectiveAcl = GetEffectiveAcl(this.props.Document); + if (added.length) { - if (this.dataDoc[AclSym] === AclReadonly) { + if (effectiveAcl === AclReadonly && !getPlaygroundMode()) { return false; - } else if (this.dataDoc[AclSym] === AclAddonly) { - added.map(doc => Doc.AddDocToList(targetDataDoc, this.props.fieldKey, doc)); - } else { - added.map(doc => { - const context = Cast(doc.context, Doc, null); - if (context && (context.type === DocumentType.VID || context.type === DocumentType.WEB || context.type === DocumentType.PDF || context.type === DocumentType.IMG)) { - const pushpin = Docs.Create.FontIconDocument({ - title: "pushpin", - icon: "map-pin", x: Cast(doc.x, "number", null), y: Cast(doc.y, "number", null), _backgroundColor: "#0000003d", color: "#ACCEF7", - _width: 15, _height: 15, _xPadding: 0, isLinkButton: true, displayTimecode: Cast(doc.displayTimecode, "number", null) - }); - pushpin.isPushpin = true; - Doc.GetProto(pushpin).annotationOn = doc.annotationOn; - Doc.SetInPlace(doc, "annotationOn", undefined, true); - Doc.AddDocToList(context, Doc.LayoutFieldKey(context) + "-annotations", pushpin); - const pushpinLink = DocUtils.MakeLink({ doc: pushpin }, { doc: doc }, "pushpin", ""); - doc.displayTimecode = undefined; - } - doc.context = this.props.Document; - }); - added.map(add => Doc.AddDocToList(Cast(Doc.UserDoc().myCatalog, Doc, null), "data", add)); - targetDataDoc[this.props.fieldKey] = new List<Doc>([...docList, ...added]); - targetDataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); + } + else { + if (this.props.Document[AclSym]) { + // change so it only adds if more restrictive + added.forEach(d => { + const dataDoc = d[DataSym]; + for (const [key, value] of Object.entries(this.props.Document[AclSym])) { + distributeAcls(key, this.AclMap.get(value) as SharingPermissions, d); + } + dataDoc[AclSym] = d[AclSym] = this.props.Document[AclSym]; + }); + } + + if (effectiveAcl === AclAddonly) { + added.map(doc => Doc.AddDocToList(targetDataDoc, this.props.fieldKey, doc)); + } + else { + added.map(doc => { + const context = Cast(doc.context, Doc, null); + if (context && (context.type === DocumentType.VID || context.type === DocumentType.WEB || context.type === DocumentType.PDF || context.type === DocumentType.IMG)) { + const pushpin = Docs.Create.FontIconDocument({ + title: "pushpin", + icon: "map-pin", x: Cast(doc.x, "number", null), y: Cast(doc.y, "number", null), _backgroundColor: "#0000003d", color: "#ACCEF7", + _width: 15, _height: 15, _xPadding: 0, isLinkButton: true, displayTimecode: Cast(doc.displayTimecode, "number", null) + }); + pushpin.isPushpin = true; + Doc.GetProto(pushpin).annotationOn = doc.annotationOn; + Doc.SetInPlace(doc, "annotationOn", undefined, true); + Doc.AddDocToList(context, Doc.LayoutFieldKey(context) + "-annotations", pushpin); + const pushpinLink = DocUtils.MakeLink({ doc: pushpin }, { doc: doc }, "pushpin", ""); + doc.displayTimecode = undefined; + } + doc.context = this.props.Document; + }); + added.map(add => Doc.AddDocToList(Cast(Doc.UserDoc().myCatalog, Doc, null), "data", add)); + targetDataDoc[this.props.fieldKey] = new List<Doc>([...docList, ...added]); + targetDataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); + } } } return true; @@ -165,13 +191,15 @@ export class CollectionView extends Touchable<FieldViewProps & CollectionViewCus @action.bound removeDocument = (doc: any): boolean => { - const docs = doc instanceof Doc ? [doc] : doc as Doc[]; - const targetDataDoc = this.props.Document[DataSym]; - const value = DocListCast(targetDataDoc[this.props.fieldKey]); - const result = value.filter(v => !docs.includes(v)); - if (result.length !== value.length) { - targetDataDoc[this.props.fieldKey] = new List<Doc>(result); - return true; + if (GetEffectiveAcl(this.props.Document) === AclEdit || getPlaygroundMode()) { + const docs = doc instanceof Doc ? [doc] : doc as Doc[]; + const targetDataDoc = this.props.Document[DataSym]; + const value = DocListCast(targetDataDoc[this.props.fieldKey]); + const result = value.filter(v => !docs.includes(v)); + if (result.length !== value.length) { + targetDataDoc[this.props.fieldKey] = new List<Doc>(result); + return true; + } } return false; } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 2db665337..8aab2e6a5 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -1,6 +1,7 @@ import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; -import { Doc, Opt, DocListCast, DataSym } from "../../../../fields/Doc"; +import { Doc, Opt, DocListCast, DataSym, AclEdit, AclAddonly } from "../../../../fields/Doc"; +import { GetEffectiveAcl, getPlaygroundMode } from "../../../../fields/util"; import { InkData, InkField, InkTool } from "../../../../fields/InkField"; import { List } from "../../../../fields/List"; import { RichTextField } from "../../../../fields/RichTextField"; @@ -276,7 +277,8 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque } else { this._downX = x; this._downY = y; - PreviewCursor.Show(x, y, this.onKeyPress, this.props.addLiveTextDocument, this.props.getTransform, this.props.addDocument, this.props.nudge); + const effectiveAcl = GetEffectiveAcl(this.props.Document); + if (effectiveAcl === AclEdit || effectiveAcl === AclAddonly || getPlaygroundMode()) PreviewCursor.Show(x, y, this.onKeyPress, this.props.addLiveTextDocument, this.props.getTransform, this.props.addDocument, this.props.nudge); this.clearSelection(); } }); diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index f1438fd54..f2f8ada68 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -1,6 +1,6 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; -import { Doc, Opt, Field, AclSym, AclPrivate } from "../../../fields/Doc"; +import { Doc, Opt, Field, AclPrivate } from "../../../fields/Doc"; import { Cast, StrCast, NumCast } from "../../../fields/Types"; import { OmitKeys, Without, emptyPath } from "../../../Utils"; import { DirectoryImportBox } from "../../util/Import & Export/DirectoryImportBox"; @@ -36,7 +36,7 @@ import { WebBox } from "./WebBox"; import { InkingStroke } from "../InkingStroke"; import React = require("react"); import { RecommendationsBox } from "../RecommendationsBox"; -import { TraceMobx } from "../../../fields/util"; +import { TraceMobx, GetEffectiveAcl } from "../../../fields/util"; import { ScriptField } from "../../../fields/ScriptField"; import XRegExp = require("xregexp"); @@ -184,8 +184,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { const bindings = this.CreateBindings(onClick, onInput); // layoutFrame = splits.length > 1 ? splits[0] + splits[1].replace(/{([^{}]|(?R))*}/, replacer4) : ""; // might have been more elegant if javascript supported recursive patterns - - return (this.props.renderDepth > 12 || !layoutFrame || !this.layoutDoc || this.layoutDoc[AclSym] === AclPrivate) ? (null) : + return (this.props.renderDepth > 12 || !layoutFrame || !this.layoutDoc || GetEffectiveAcl(this.layoutDoc) === AclPrivate) ? (null) : <ObserverJsxParser key={42} blacklistedAttrs={[]} diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index c0a8b3a59..803720417 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; import * as rp from "request-promise"; -import { Doc, DocListCast, HeightSym, Opt, WidthSym, DataSym, AclSym, AclReadonly, AclPrivate } from "../../../fields/Doc"; +import { Doc, DocListCast, HeightSym, Opt, WidthSym, DataSym, AclPrivate, AclReadonly } from "../../../fields/Doc"; import { Document } from '../../../fields/documentSchemas'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; @@ -12,7 +12,7 @@ import { listSpec } from "../../../fields/Schema"; import { SchemaHeaderField } from '../../../fields/SchemaHeaderField'; import { ScriptField } from '../../../fields/ScriptField'; import { BoolCast, Cast, NumCast, StrCast, ScriptCast } from "../../../fields/Types"; -import { TraceMobx } from '../../../fields/util'; +import { TraceMobx, GetEffectiveAcl } from '../../../fields/util'; import { GestureUtils } from '../../../pen-gestures/GestureUtils'; import { emptyFunction, OmitKeys, returnOne, returnTransparent, Utils, emptyPath } from "../../../Utils"; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; @@ -26,7 +26,7 @@ import { InteractionUtils } from '../../util/InteractionUtils'; import { Scripting } from '../../util/Scripting'; import { SearchUtil } from '../../util/SearchUtil'; import { SelectionManager } from "../../util/SelectionManager"; -import SharingManager from '../../util/SharingManager'; +import SharingManager, { SharingPermissions } from '../../util/SharingManager'; import { Transform } from "../../util/Transform"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { CollectionView, CollectionViewType } from '../collections/CollectionView'; @@ -699,7 +699,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @undoBatch @action - setAcl = (acl: "readOnly" | "addOnly" | "ownerOnly" | "write") => { + setAcl = (acl: SharingPermissions) => { this.dataDoc.ACL = this.props.Document.ACL = acl; DocListCast(this.dataDoc[Doc.LayoutFieldKey(this.dataDoc)]).map(d => { if (d.author === Doc.CurrentUserEmail) d.ACL = acl; @@ -709,7 +709,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } @undoBatch @action - testAcl = (acl: "readOnly" | "addOnly" | "ownerOnly" | "write") => { + testAcl = (acl: SharingPermissions) => { this.dataDoc.author = this.props.Document.author = "ADMIN"; this.dataDoc.ACL = this.props.Document.ACL = acl; DocListCast(this.dataDoc[Doc.LayoutFieldKey(this.dataDoc)]).map(d => { @@ -819,14 +819,16 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu // const existingAcls = cm.findByDescription("Privacy..."); // const aclItems: ContextMenuProps[] = existingAcls && "subitems" in existingAcls ? existingAcls.subitems : []; - // aclItems.push({ description: "Make Add Only", event: () => this.setAcl("addOnly"), icon: "concierge-bell" }); - // aclItems.push({ description: "Make Read Only", event: () => this.setAcl("readOnly"), icon: "concierge-bell" }); - // aclItems.push({ description: "Make Private", event: () => this.setAcl("ownerOnly"), icon: "concierge-bell" }); - // aclItems.push({ description: "Make Editable", event: () => this.setAcl("write"), icon: "concierge-bell" }); - // aclItems.push({ description: "Test Private", event: () => this.testAcl("ownerOnly"), icon: "concierge-bell" }); - // aclItems.push({ description: "Test Readonly", event: () => this.testAcl("readOnly"), icon: "concierge-bell" }); + // aclItems.push({ description: "Make Add Only", event: () => this.setAcl(SharingPermissions.Add), icon: "concierge-bell" }); + // aclItems.push({ description: "Make Read Only", event: () => this.setAcl(SharingPermissions.View), icon: "concierge-bell" }); + // aclItems.push({ description: "Make Private", event: () => this.setAcl(SharingPermissions.None), icon: "concierge-bell" }); + // aclItems.push({ description: "Make Editable", event: () => this.setAcl(SharingPermissions.Edit), icon: "concierge-bell" }); + // aclItems.push({ description: "Test Private", event: () => this.testAcl(SharingPermissions.None), icon: "concierge-bell" }); + // aclItems.push({ description: "Test Readonly", event: () => this.testAcl(SharingPermissions.View), icon: "concierge-bell" }); // !existingAcls && cm.addItem({ description: "Privacy...", subitems: aclItems, icon: "question" }); + // cm.addItem({ description: `${getPlaygroundMode() ? "Disable" : "Enable"} playground mode`, event: togglePlaygroundMode, icon: "concierge-bell" }); + // const recommender_subitems: ContextMenuProps[] = []; // recommender_subitems.push({ @@ -1184,7 +1186,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu render() { if (!(this.props.Document instanceof Doc)) return (null); - if (this.props.Document[AclSym] && this.props.Document[AclSym] === AclPrivate) return (null); + if (GetEffectiveAcl(this.props.Document) === AclPrivate) return (null); if (this.props.Document.hidden) return (null); const backgroundColor = Doc.UserDoc().renderStyle === "comic" ? undefined : this.props.forcedBackgroundColor?.(this.Document) || StrCast(this.layoutDoc._backgroundColor) || StrCast(this.layoutDoc.backgroundColor) || StrCast(this.Document.backgroundColor) || this.props.backgroundColor?.(this.Document); const opacity = Cast(this.layoutDoc._opacity, "number", Cast(this.layoutDoc.opacity, "number", Cast(this.Document.opacity, "number", null))); diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 30e1fa9cd..7906e2533 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -13,7 +13,7 @@ import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from " import { ReplaceStep } from 'prosemirror-transform'; import { EditorView } from "prosemirror-view"; import { DateField } from '../../../../fields/DateField'; -import { DataSym, Doc, DocListCast, DocListCastAsync, Field, HeightSym, Opt, WidthSym, AclSym } from "../../../../fields/Doc"; +import { DataSym, Doc, DocListCast, DocListCastAsync, Field, HeightSym, Opt, WidthSym, AclEdit } from "../../../../fields/Doc"; import { documentSchema } from '../../../../fields/documentSchemas'; import applyDevTools = require("prosemirror-dev-tools"); import { removeMarkWithAttrs } from "./prosemirrorPatches"; @@ -24,7 +24,7 @@ import { RichTextField } from "../../../../fields/RichTextField"; import { RichTextUtils } from '../../../../fields/RichTextUtils'; import { createSchema, makeInterface } from "../../../../fields/Schema"; import { Cast, DateCast, NumCast, StrCast, ScriptCast } from "../../../../fields/Types"; -import { TraceMobx, OVERRIDE_ACL } from '../../../../fields/util'; +import { TraceMobx, OVERRIDE_ACL, GetEffectiveAcl } from '../../../../fields/util'; import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, returnOne, returnZero, Utils, setupMoveUpEvents } from '../../../../Utils'; import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; import { DocServer } from "../../../DocServer"; @@ -233,12 +233,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp const curLayout = this.rootDoc !== this.layoutDoc ? Cast(this.layoutDoc[this.fieldKey], RichTextField, null) : undefined; // the default text stored in a layout template const json = JSON.stringify(state.toJSON()); let unchanged = true; - if (!this.dataDoc[AclSym]) { + if (GetEffectiveAcl(this.dataDoc) === AclEdit) { if (!this._applyingChange && json.replace(/"selection":.*/, "") !== curProto?.Data.replace(/"selection":.*/, "")) { this._applyingChange = true; (curText !== Cast(this.dataDoc[this.fieldKey], RichTextField)?.Text) && (this.dataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now()))); if ((!curTemp && !curProto) || curText || curLayout?.Data.includes("dash")) { // if no template, or there's text that didn't come from the layout template, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) - if (json !== curLayout?.Data) { + if (json.replace(/"selection":.*/, "") !== curLayout?.Data.replace(/"selection":.*/, "")) { !curText && tx.storedMarks?.map(m => m.type.name === "pFontSize" && (Doc.UserDoc().fontSize = this.layoutDoc._fontSize = m.attrs.fontSize)); !curText && tx.storedMarks?.map(m => m.type.name === "pFontFamily" && (Doc.UserDoc().fontFamily = this.layoutDoc._fontFamily = m.attrs.fontFamily)); this.dataDoc[this.props.fieldKey] = new RichTextField(json, curText); diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 3ad9f4e41..2f3b7025e 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -1,4 +1,4 @@ -import { action, computed, observable, ObservableMap, runInAction } from "mobx"; +import { action, computed, observable, ObservableMap, runInAction, untracked } from "mobx"; import { computedFn } from "mobx-utils"; import { alias, map, serializable } from "serializr"; import { DocServer } from "../client/DocServer"; @@ -17,8 +17,9 @@ import { RichTextField } from "./RichTextField"; import { listSpec } from "./Schema"; import { ComputedField } from "./ScriptField"; import { Cast, FieldValue, NumCast, StrCast, ToConstructor } from "./Types"; -import { deleteProperty, getField, getter, makeEditable, makeReadOnly, setter, updateFunction } from "./util"; +import { deleteProperty, getField, getter, makeEditable, makeReadOnly, setter, updateFunction, GetEffectiveAcl } from "./util"; import { LinkManager } from "../client/util/LinkManager"; +import { SharingPermissions } from "../client/util/SharingManager"; export namespace Field { export function toKeyValueString(doc: Doc, key: string): string { @@ -96,28 +97,27 @@ export const AclSym = Symbol("Acl"); export const AclPrivate = Symbol("AclOwnerOnly"); export const AclReadonly = Symbol("AclReadOnly"); export const AclAddonly = Symbol("AclAddonly"); -export const AclReadWrite = Symbol("AclReadWrite"); +export const AclEdit = Symbol("AclEdit"); export const UpdatingFromServer = Symbol("UpdatingFromServer"); const CachedUpdates = Symbol("Cached updates"); +const AclMap = new Map<string, symbol>([ + [SharingPermissions.None, AclPrivate], + [SharingPermissions.View, AclReadonly], + [SharingPermissions.Add, AclAddonly], + [SharingPermissions.Edit, AclEdit] +]); export function fetchProto(doc: Doc) { - if (doc.author !== Doc.CurrentUserEmail) { - const acl = Doc.Get(doc, "ACL", true); - switch (acl) { - case "ownerOnly": - doc[AclSym] = AclPrivate; - return undefined; - case "readOnly": - doc[AclSym] = AclReadonly; - break; - case "addOnly": - doc[AclSym] = AclAddonly; - break; - case "write": - doc[AclSym] = AclReadWrite; - } - } + // if (doc.author !== Doc.CurrentUserEmail) { + untracked(() => { + const permissions: { [key: string]: symbol } = {}; + + Object.keys(doc).filter(key => key.startsWith("ACL")).forEach(key => permissions[key] = AclMap.get(StrCast(doc[key]))!); + + if (Object.keys(permissions).length) doc[AclSym] = permissions; + }); + // } if (doc.proto instanceof Promise) { doc.proto.then(fetchProto); @@ -134,10 +134,10 @@ export class Doc extends RefField { set: setter, get: getter, // getPrototypeOf: (target) => Cast(target[SelfProxy].proto, Doc) || null, // TODO this might be able to replace the proto logic in getter - has: (target, key) => target[AclSym] !== AclPrivate && key in target.__fields, + has: (target, key) => GetEffectiveAcl(target) !== AclPrivate && key in target.__fields, ownKeys: target => { const obj = {} as any; - if (target[AclSym] !== AclPrivate) Object.assign(obj, target.___fields); + if (GetEffectiveAcl(target) !== AclPrivate) Object.assign(obj, target.___fields); runInAction(() => obj.__LAYOUT__ = target.__LAYOUT__); return Object.keys(obj); }, @@ -191,11 +191,11 @@ export class Doc extends RefField { private [Self] = this; private [SelfProxy]: any; - public [AclSym]: any = undefined; + public [AclSym]: { [key: string]: symbol }; public [WidthSym] = () => NumCast(this[SelfProxy]._width); public [HeightSym] = () => NumCast(this[SelfProxy]._height); public [ToScriptString]() { return `DOC-"${this[Self][Id]}"-`; } - public [ToString]() { return `Doc(${this[AclSym] === AclPrivate ? "-inaccessible-" : this.title})`; } + public [ToString]() { return `Doc(${GetEffectiveAcl(this) === AclPrivate ? "-inaccessible-" : this.title})`; } public get [LayoutSym]() { return this[SelfProxy].__LAYOUT__; } public get [DataSym]() { const self = this[SelfProxy]; @@ -215,8 +215,8 @@ export class Doc extends RefField { return Cast(this[SelfProxy][renderFieldKey + "-layout[" + templateLayoutDoc[Id] + "]"], Doc, null) || templateLayoutDoc; } return undefined; - } + } private [CachedUpdates]: { [key: string]: () => void | Promise<any> } = {}; public static CurrentUserEmail: string = ""; @@ -823,7 +823,7 @@ export namespace Doc { } // don't bother memoizing (caching) the result if called from a non-reactive context. (plus this avoids a warning message) export function IsBrushedDegreeUnmemoized(doc: Doc) { - if (!doc || doc[AclSym] === AclPrivate || Doc.GetProto(doc)[AclSym] === AclPrivate) return 0; + if (!doc || GetEffectiveAcl(doc) === AclPrivate || GetEffectiveAcl(Doc.GetProto(doc)) === AclPrivate) return 0; return brushManager.BrushedDoc.has(doc) ? 2 : brushManager.BrushedDoc.has(Doc.GetProto(doc)) ? 1 : 0; } export function IsBrushedDegree(doc: Doc) { @@ -832,15 +832,14 @@ export namespace Doc { })(doc); } export function BrushDoc(doc: Doc) { - - if (!doc || doc[AclSym] === AclPrivate || Doc.GetProto(doc)[AclSym] === AclPrivate) return doc; + if (!doc || GetEffectiveAcl(doc) === AclPrivate || GetEffectiveAcl(Doc.GetProto(doc)) === AclPrivate) return doc; brushManager.BrushedDoc.set(doc, true); brushManager.BrushedDoc.set(Doc.GetProto(doc), true); return doc; } export function UnBrushDoc(doc: Doc) { - if (!doc || doc[AclSym] === AclPrivate || Doc.GetProto(doc)[AclSym] === AclPrivate) return doc; + if (!doc || GetEffectiveAcl(doc) === AclPrivate || GetEffectiveAcl(Doc.GetProto(doc)) === AclPrivate) return doc; brushManager.BrushedDoc.delete(doc); brushManager.BrushedDoc.delete(Doc.GetProto(doc)); return doc; @@ -870,7 +869,7 @@ export namespace Doc { } const highlightManager = new HighlightBrush(); export function IsHighlighted(doc: Doc) { - if (!doc || doc[AclSym] === AclPrivate || Doc.GetProto(doc)[AclSym] === AclPrivate) return false; + if (!doc || GetEffectiveAcl(doc) === AclPrivate || GetEffectiveAcl(Doc.GetProto(doc)) === AclPrivate) return false; return highlightManager.HighlightedDoc.get(doc) || highlightManager.HighlightedDoc.get(Doc.GetProto(doc)); } export function HighlightDoc(doc: Doc, dataAndDisplayDocs = true) { diff --git a/src/fields/util.ts b/src/fields/util.ts index 2dc21c987..a714b01e3 100644 --- a/src/fields/util.ts +++ b/src/fields/util.ts @@ -1,5 +1,5 @@ import { UndoManager } from "../client/util/UndoManager"; -import { Doc, Field, FieldResult, UpdatingFromServer, LayoutSym, AclSym, AclPrivate } from "./Doc"; +import { Doc, FieldResult, UpdatingFromServer, LayoutSym, AclPrivate, AclEdit, AclReadonly, AclAddonly, AclSym, fetchProto, DataSym, DocListCast } from "./Doc"; import { SerializationHelper } from "../client/util/SerializationHelper"; import { ProxyField, PrefetchProxy } from "./Proxy"; import { RefField } from "./RefField"; @@ -8,7 +8,8 @@ import { action, trace } from "mobx"; import { Parent, OnUpdate, Update, Id, SelfProxy, Self } from "./FieldSymbols"; import { DocServer } from "../client/DocServer"; import { ComputedField } from "./ScriptField"; -import { ScriptCast } from "./Types"; +import { ScriptCast, StrCast } from "./Types"; +import { SharingPermissions } from "../client/util/SharingManager"; function _readOnlySetter(): never { @@ -70,8 +71,9 @@ const _setterImpl = action(function (target: any, prop: string | symbol | number const writeMode = DocServer.getFieldWriteMode(prop as string); const fromServer = target[UpdatingFromServer]; const sameAuthor = fromServer || (receiver.author === Doc.CurrentUserEmail); - const writeToDoc = sameAuthor || (writeMode !== DocServer.WriteMode.LiveReadonly); - const writeToServer = sameAuthor || (writeMode === DocServer.WriteMode.Default); + const writeToDoc = sameAuthor || GetEffectiveAcl(target) === AclEdit || (writeMode !== DocServer.WriteMode.LiveReadonly); + const writeToServer = (sameAuthor || GetEffectiveAcl(target) === AclEdit || writeMode === DocServer.WriteMode.Default) && !playgroundMode; + if (writeToDoc) { if (value === undefined) { delete target.__fields[prop]; @@ -79,6 +81,7 @@ const _setterImpl = action(function (target: any, prop: string | symbol | number target.__fields[prop] = value; } //if (typeof value === "object" && !(value instanceof ObjectField)) debugger; + if (writeToServer) { if (value === undefined) target[Update]({ '$unset': { ["fields." + prop]: "" } }); else target[Update]({ '$set': { ["fields." + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) : (value === undefined ? null : value) } }); @@ -89,8 +92,9 @@ const _setterImpl = action(function (target: any, prop: string | symbol | number redo: () => receiver[prop] = value, undo: () => receiver[prop] = curValue }); + return true; } - return true; + return false; }); let _setter: (target: any, prop: string | symbol | number, value: any, receiver: any) => boolean = _setterImpl; @@ -107,11 +111,116 @@ export function OVERRIDE_ACL(val: boolean) { _overrideAcl = val; } +let playgroundMode = false; + +export function togglePlaygroundMode() { + playgroundMode = !playgroundMode; +} + +export function getPlaygroundMode() { + return playgroundMode; +} + +let currentUserGroups: string[] = []; + +export function setGroups(groups: string[]) { + currentUserGroups = groups; +} + +export function GetEffectiveAcl(target: any, in_prop?: string | symbol | number): symbol { + if (in_prop === UpdatingFromServer || target[UpdatingFromServer]) return AclEdit; + + if (!target[AclSym] && target instanceof Doc) { + fetchProto(target); + } + + + if (target[AclSym] && Object.keys(target[AclSym]).length) { + + // console.log(target[AclSym]); + + if (target.__fields?.author === Doc.CurrentUserEmail || target.author === Doc.CurrentUserEmail || currentUserGroups.includes("admin")) return AclEdit; + + if (_overrideAcl || (in_prop && DocServer.PlaygroundFields?.includes(in_prop.toString()))) return AclEdit; + + // if (target[AclSym].ACL) return target[AclSym].ACL; + + let effectiveAcl = AclPrivate; + let aclPresent = false; + + const HierarchyMapping = new Map<symbol, number>([ + [AclPrivate, 0], + [AclReadonly, 1], + [AclAddonly, 2], + [AclEdit, 3] + ]); + + for (const [key, value] of Object.entries(target[AclSym])) { + if (currentUserGroups.includes(key.substring(4)) || Doc.CurrentUserEmail === key.substring(4).replace("_", ".")) { + if (HierarchyMapping.get(value as symbol)! >= HierarchyMapping.get(effectiveAcl)!) { + aclPresent = true; + effectiveAcl = value as symbol; + if (effectiveAcl === AclEdit) break; + } + } + } + return aclPresent ? effectiveAcl : AclEdit; + } + return AclEdit; +} + +export function distributeAcls(key: string, acl: SharingPermissions, target: Doc) { + + const HierarchyMapping = new Map<string, number>([ + ["Not Shared", 0], + ["Can View", 1], + ["Can Add", 2], + ["Can Edit", 3] + ]); + + const dataDoc = target[DataSym]; + + if (!target[key] || HierarchyMapping.get(StrCast(target[key]))! < HierarchyMapping.get(acl)!) target[key] = acl; + + if (dataDoc && (!dataDoc[key] || HierarchyMapping.get(StrCast(dataDoc[key]))! < HierarchyMapping.get(acl)!)) { + dataDoc[key] = acl; + + DocListCast(dataDoc[Doc.LayoutFieldKey(dataDoc)]).map(d => { + if (d.author === Doc.CurrentUserEmail && d[key] && HierarchyMapping.get(StrCast(d[key]))! < HierarchyMapping.get(acl)!) { + distributeAcls(key, acl, d); + d[key] = acl; + } + const data = d[DataSym]; + if (data && data.author === Doc.CurrentUserEmail && data[key] && HierarchyMapping.get(StrCast(data[key]))! < HierarchyMapping.get(acl)!) { + distributeAcls(key, acl, data); + data[key] = acl; + } + }); + + DocListCast(dataDoc[Doc.LayoutFieldKey(dataDoc) + "-annotations"]).map(d => { + if (d.author === Doc.CurrentUserEmail && d[key] && HierarchyMapping.get(StrCast(d[key]))! < HierarchyMapping.get(acl)!) { + distributeAcls(key, acl, d); + d[key] = acl; + } + const data = d[DataSym]; + if (data && data.author === Doc.CurrentUserEmail && data[key] && HierarchyMapping.get(StrCast(data[key]))! < HierarchyMapping.get(acl)!) { + distributeAcls(key, acl, data); + data[key] = acl; + } + }); + } +} + const layoutProps = ["panX", "panY", "width", "height", "nativeWidth", "nativeHeight", "fitWidth", "fitToBox", "chromeStatus", "viewType", "gridGap", "xMargin", "yMargin", "autoHeight"]; export function setter(target: any, in_prop: string | symbol | number, value: any, receiver: any): boolean { let prop = in_prop; - if (target[AclSym] && !_overrideAcl && !DocServer.PlaygroundFields.includes(in_prop.toString())) return true; + if (GetEffectiveAcl(target, in_prop) !== AclEdit) { + return true; + } + + if (typeof prop === "string" && prop.startsWith("ACL") && !["Can Edit", "Can Add", "Can View", "Not Shared", undefined].includes(value)) return true; + if (typeof prop === "string" && prop !== "__id" && prop !== "__fields" && (prop.startsWith("_") || layoutProps.includes(prop))) { if (!prop.startsWith("_")) { console.log(prop + " is deprecated - switch to _" + prop); @@ -131,7 +240,7 @@ export function setter(target: any, in_prop: string | symbol | number, value: an export function getter(target: any, in_prop: string | symbol | number, receiver: any): any { let prop = in_prop; if (in_prop === AclSym) return _overrideAcl ? undefined : target[AclSym]; - if (target[AclSym] === AclPrivate && !_overrideAcl) return undefined; + if (GetEffectiveAcl(target) === AclPrivate && !_overrideAcl) return undefined; if (prop === LayoutSym) { return target.__LAYOUT__; } @@ -168,7 +277,7 @@ function getFieldImpl(target: any, prop: string | number, receiver: any, ignoreP } if (field === undefined && !ignoreProto && prop !== "proto") { const proto = getFieldImpl(target, "proto", receiver, true);//TODO tfs: instead of receiver we could use target[SelfProxy]... I don't which semantics we want or if it really matters - if (proto instanceof Doc && proto[AclSym] !== AclPrivate) { + if (proto instanceof Doc && GetEffectiveAcl(proto) !== AclPrivate) { return getFieldImpl(proto[Self], prop, receiver, ignoreProto); } return undefined; diff --git a/src/mobile/AudioUpload.tsx b/src/mobile/AudioUpload.tsx index 590bf8f9d..b75102e43 100644 --- a/src/mobile/AudioUpload.tsx +++ b/src/mobile/AudioUpload.tsx @@ -143,6 +143,7 @@ export class AudioUpload extends React.Component { interactive={true} dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} overlayDisplayedOpacity={this.overlayOpacity} + closeOnExternalClick={this.closeUpload} /> ); } diff --git a/src/mobile/ImageUpload.tsx b/src/mobile/ImageUpload.tsx index e13c91db9..c6420bab5 100644 --- a/src/mobile/ImageUpload.tsx +++ b/src/mobile/ImageUpload.tsx @@ -172,6 +172,7 @@ export class Uploader extends React.Component<ImageUploadProps> { interactive={true} dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} overlayDisplayedOpacity={this.overlayOpacity} + closeOnExternalClick={this.closeUpload} /> ); } |