Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions localtypings/pxtarget.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ declare namespace pxt {
pxtJsonOptions?: PxtJsonOption[];
enabledFeatures?: pxt.Map<FeatureFlag>;
forceEnableAiErrorHelp?: boolean; // Enables the AI Error Help feature, regardless of geo setting.
shareHomepageContent?: boolean; // Show buttons to share links to homepage content more easily
}

interface DownloadDialogTheme {
Expand Down
94 changes: 94 additions & 0 deletions react-common/components/share/ShareLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as React from "react";

import { Modal } from "../controls/Modal";
import { Input } from "../controls/Input";
import { Button } from "../controls/Button";

export interface ShareLinkDialogProps {
visible: boolean;
shareUrl?: string;
title?: string;
ariaLabel?: string;
linkAriaLabel?: string;
className?: string;
children?: React.ReactNode;
onClose: () => void;
}

export const ShareLinkDialog: React.FC<ShareLinkDialogProps> = props => {
const {
visible,
shareUrl,
title,
ariaLabel,
linkAriaLabel,
className,
children,
onClose,
} = props;

const [copySuccessful, setCopySuccessful] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);

React.useEffect(() => {
if (visible) setCopySuccessful(false);
}, [visible, shareUrl]);

const handleCopyClick = () => {
if (!shareUrl) return;

if (pxt.BrowserUtils.isIpcRenderer()) {
setCopySuccessful(!!inputRef.current && pxt.BrowserUtils.legacyCopyText(inputRef.current));
return;
}

navigator.clipboard.writeText(shareUrl);
setCopySuccessful(true);
};

const handleCopyBlur = () => {
setCopySuccessful(false);
};

if (!visible) return <></>;

const modalClassName = ["sharedialog", "share-link-dialog", className].filter(Boolean).join(" ");

return <Modal
title={title || lf("Share")}
className={modalClassName}
parentElement={document.getElementById("root") || undefined}
onClose={onClose}
ariaLabel={ariaLabel || lf("Share link modal")}
>
<div className="project-share">
<div className="project-share-info">
<div className="project-share-content">
<div className="project-share-data">
{!!children && <div className="share-link-dialog-top">
{children}
</div>}

<div className="common-input-attached-button">
<Input
ariaLabel={linkAriaLabel || lf("Shareable link")}
handleInputRef={(ref: HTMLInputElement) => inputRef.current = ref}
initialValue={shareUrl}
readOnly={true}
selectOnClick={true}
/>
<Button
className={copySuccessful ? "green" : "primary"}
title={lf("Copy link")}
label={copySuccessful ? lf("Copied!") : lf("Copy")}
leftIcon="fas fa-link"
onClick={handleCopyClick}
onBlur={handleCopyBlur}
/>
</div>
</div>
</div>
</div>
</div>
</Modal>;
};
6 changes: 6 additions & 0 deletions react-common/styles/share/share.less
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@
padding: .8rem 1rem .95rem;
}

.share-link-dialog {
.share-link-dialog-top {
margin-bottom: 0.75rem;
}
}

.project-share-vscode {
display: flex;
flex-direction: column;
Expand Down
2 changes: 1 addition & 1 deletion theme/home.less
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@
.detail {
color: var(--pxt-neutral-foreground3);
}
.yt-button {
.yt-button, button.home-share-button {
display: inline-block;
border-radius: 0.5rem;
background-color: var(--pxt-primary-background);
Expand Down
165 changes: 162 additions & 3 deletions webapp/src/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { showAboutDialogAsync } from "./dialogs";
import { fireClickOnEnter } from "./util";
import { sendUpdateFeedbackTheme } from "../../react-common/components/controls/Feedback/FeedbackEventListener";
import { ThemeManager } from "../../react-common/components/theming/themeManager";
import { ShareLinkDialog } from "../../react-common/components/share/ShareLinkDialog";
import { EditorToggle } from "../../react-common/components/controls/EditorToggle";

import IProjectView = pxt.editor.IProjectView;
import ISettingsProps = pxt.editor.ISettingsProps;
Expand Down Expand Up @@ -898,6 +900,8 @@ export interface ProjectsDetailProps extends ISettingsProps {
}

export interface ProjectsDetailState {
shareDialogVisible?: boolean;
shareDialogEditor?: pxt.CodeCardEditorType;
}

export class ProjectsDetail extends data.Component<ProjectsDetailProps, ProjectsDetailState> {
Expand All @@ -906,13 +910,63 @@ export class ProjectsDetail extends data.Component<ProjectsDetailProps, Projects
constructor(props: ProjectsDetailProps) {
super(props);
this.state = {
shareDialogVisible: false,
shareDialogEditor: undefined
}

this.handleDetailClick = this.handleDetailClick.bind(this);
this.handleOpenForumUrlInEditor = this.handleOpenForumUrlInEditor.bind(this);
this.showShareDialog = this.showShareDialog.bind(this);
this.hideShareDialog = this.hideShareDialog.bind(this);
this.setShareDialogEditor = this.setShareDialogEditor.bind(this);
this.linkRef = React.createRef<HTMLAnchorElement>();
}

private showShareDialog() {
pxt.tickEvent("projects.share.open", { cardType: this.props.cardType }, { interactiveConsent: true });
const editors = this.getShareableEditors();
const previousEditor = this.state.shareDialogEditor;
const selectedEditor = previousEditor && editors.indexOf(previousEditor) !== -1
? previousEditor
: editors[0];
this.setState({
shareDialogVisible: true,
shareDialogEditor: selectedEditor
});
}

private hideShareDialog() {
this.setState({ shareDialogVisible: false });
}

private setShareDialogEditor(editor: pxt.CodeCardEditorType) {
this.setState({ shareDialogEditor: editor });
}

private getShareableEditors(): pxt.CodeCardEditorType[] {
const { cardType, otherActions } = this.props;

if (cardType !== "tutorial" && cardType !== "example" && cardType !== "codeExample")
return [undefined];

const available: pxt.CodeCardEditorType[] = [];

const addEditor = (editor: pxt.CodeCardEditorType) => {
if (!editor) return;
if (available.indexOf(editor) === -1) available.push(editor);
}

addEditor(this.getActionEditor(cardType, undefined));

for (const action of (otherActions ?? [])) {
const actionCardType: pxt.CodeCardType = action.cardType || cardType;
if (actionCardType !== cardType) continue;
addEditor(this.getActionEditor(actionCardType, action));
}

return available;
}

protected isLink(actionType?: pxt.CodeCardType) {
return cardIsLink(this.props, actionType);
}
Expand Down Expand Up @@ -1018,6 +1072,71 @@ export class ProjectsDetail extends data.Component<ProjectsDetailProps, Projects
</div>
}

protected getShareableLink(overrideEditor?: pxt.CodeCardEditorType): string {
const { cardType, url, scr } = this.props;
if (!cardType) return undefined;

const relPrefix = pxt.webConfig?.relprefix?.replace(/-+$/, "");
const baseUrl = `${window.location.origin}${relPrefix}`;

const cardUrl = (scr?.url || url) as string;
const defaultEditor = this.getActionEditor(cardType, undefined);
const includeEditorPrefix = !!overrideEditor
&& overrideEditor !== defaultEditor;
const editorPrefix = includeEditorPrefix ? normalizeEditorPrefix(overrideEditor) : "";

switch (cardType) {
case "tutorial": {
let tutorialPath = (cardUrl || "").trim();
if (!tutorialPath) return undefined;
return `${baseUrl}#tutorial:${editorPrefix}${tutorialPath}`;
}
case "example":
case "codeExample": {
let examplePath = (cardUrl || "").trim();
if (!examplePath) return undefined;
return `${baseUrl}#example:${editorPrefix}${examplePath}`;
}
case "sharedExample": {
const raw = cardUrl || (scr as any)?.shareUrl;
if (!raw) return undefined;

const repoId = pxt.github.normalizeRepoId(raw);
if (repoId) return `${baseUrl}#github:${repoId}`;

const scriptId = pxt.Cloud.parseScriptId(raw);
if (scriptId) return `${baseUrl}#pub:${scriptId}`;

if (/^https?:\/\//i.test(raw)) return raw;
return undefined;
}
default: {
if (!cardUrl) return undefined;
if (/^(https?:)?\/\//i.test(cardUrl)) return cardUrl;
return new URL(cardUrl, baseUrl).toString();
}
}

function normalizeEditorPrefix(editor: string): string {
const normalized = editor.toLowerCase();
switch (normalized) {
case "typescript":
case "ts":
case "javascript":
case "js":
return "js:";
case "python":
case "py":
return "py:";
case "block":
case "blocks":
return "blocks:";
default:
return "";
}
}
}

handleDetailClick() {
const { scr, onClick } = this.props;
pxt.tickEvent('projects.actions.details', {
Expand Down Expand Up @@ -1085,15 +1204,24 @@ export class ProjectsDetail extends data.Component<ProjectsDetailProps, Projects
const image = !highContrast && (largeImageUrl || (youTubeId && `https://img.youtube.com/vi/${youTubeId}/0.jpg`));
const video = !highContrast && !pxt.BrowserUtils.isElectron() && !pxt.BrowserUtils.isIOS() && videoUrl;
const showVideoOrImage = !pxt.appTarget.appTheme.hideHomeDetailsVideo;
const youTubeWatchUrl = pxt.youtube.watchUrl(youTubeId, youTubePlaylistId)
const youTubeWatchUrl = pxt.youtube.watchUrl(youTubeId, youTubePlaylistId);
const shareableLink = pxt.appTarget.appTheme.shareHomepageContent ? this.getShareableLink() : undefined;
const shareEditors = this.getShareableEditors().filter(e => !!e) as pxt.CodeCardEditorType[];
const shareDialogEditor = this.state.shareDialogEditor && shareEditors.indexOf(this.state.shareDialogEditor) !== -1
? this.state.shareDialogEditor
: shareEditors[0];
const shareUrlForDialog = pxt.appTarget.appTheme.shareHomepageContent
? this.getShareableLink(shareDialogEditor)
: undefined;

let clickLabel: string;
if (buttonLabel)
clickLabel = ts.pxtc.Util.rlf(buttonLabel);
else
clickLabel = this.getClickLabel(cardType);

return <div className="ui grid stackable padded">
return <>
<div className="ui grid stackable padded">
{showVideoOrImage && (video || image) && <div className="imagewrapper">
{video ? <video className="video" src={video} autoPlay={true} controls={false} loop={true} playsInline={true} />
: <div className="image" style={{ backgroundImage: `url("${image}")` }} />}
Expand All @@ -1120,6 +1248,15 @@ export class ProjectsDetail extends data.Component<ProjectsDetailProps, Projects
className={`yt-button button attached approve large inverted`}
title={lf("Open YouTube video in new window")}
/>}
{pxt.appTarget.appTheme.shareHomepageContent && !!shareableLink &&
<sui.Button
text={lf("Share This")}
className={`home-share-button button attached approve large inverted`}
onClick={this.showShareDialog}
onKeyDown={fireClickOnEnter}
title={lf("Create a link to share this content")} ariaLabel={lf("Create a link to share this content")}
/>
}
</div>
</div>
<div className="actions column ten wide">
Expand All @@ -1139,7 +1276,29 @@ export class ProjectsDetail extends data.Component<ProjectsDetailProps, Projects
}
</div>
</div>
</div>;
</div>

<ShareLinkDialog
visible={!!this.state.shareDialogVisible}
shareUrl={shareUrlForDialog}
onClose={this.hideShareDialog}
>
{shareEditors.length > 1 && <>
<div className="project-share-label">{lf("Share as")}</div>
<EditorToggle
id="homepage-share-editor-toggle"
className="slim tablet-compact"
items={shareEditors.map((e: pxt.CodeCardEditorType) => ({
label: e === "blocks" ? lf("Blocks") : e === "py" ? lf("Python") : lf("JavaScript"),
title: e === "blocks" ? lf("Share as Blocks") : e === "py" ? lf("Share as Python") : lf("Share as JavaScript"),
focusable: true,
onClick: () => this.setShareDialogEditor(e)
}))}
selected={Math.max(0, shareEditors.indexOf(shareDialogEditor))}
/>
</>}
</ShareLinkDialog>
</>;
}
}

Expand Down
Loading