diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000..d70a0f6027 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,45 @@ +ARG USERNAME=node + +# [Choice] Node.js version: 16, 14, 12 +ARG VARIANT=12-buster +FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} + +# Install tslint, typescript, commitizen. eslint is installed by javascript image +ARG NODE_MODULES="tslint-to-eslint-config typescript commitizen" +COPY library-scripts/meta.env /usr/local/etc/vscode-dev-containers +RUN su node -c "umask 0002 && npm install -g ${NODE_MODULES}" \ + && npm cache clean --force > /dev/null 2>&1 + +# [Optional] Uncomment this section to install additional OS packages. +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends vim + +# [Optional] Uncomment if you want to install an additional version of node using nvm +# ARG EXTRA_NODE_VERSION=10 +# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" + +# Install Moby CLI and Engine +COPY library-scripts/*.sh library-scripts/*.env first-run-notice.txt /tmp/library-scripts/ +RUN bash /tmp/library-scripts/sshd-debian.sh \ + && bash /tmp/library-scripts/docker-in-docker-debian.sh "true" "${USERNAME}" "true" + +# Mount for docker-in-docker +VOLUME [ "/var/lib/docker" ] + +# Fire Docker/Moby script if needed along with Oryx's benv +ENTRYPOINT [ "/usr/local/share/docker-init.sh", "/usr/local/share/ssh-init.sh" ] +CMD [ "sleep", "infinity" ] + +# [Optional] Install debugger for development of Codespaces - Not in resulting image by default +ARG DeveloperBuild +RUN if [ -z $DeveloperBuild ]; then \ + echo "not including debugger" ; \ + else \ + curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg ; \ + fi + +USER ${USERNAME} + +# Move first run notice to right spot +RUN mkdir -p /usr/local/etc/vscode-dev-containers/ \ + && mv -f /tmp/library-scripts/first-run-notice.txt /usr/local/etc/vscode-dev-containers/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..30ee459f30 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,69 @@ +// For format details, see https://aka.ms/devcontainer.json. +// This is a fork from https://github.com/microsoft/vscode-dev-containers/blob/v0.177.0/containers/codespaces-linux/.devcontainer/ + +{ + "name": "Matters Codespaces", + + "build": { + "dockerfile": "Dockerfile" + }, + + "runArgs": ["--init", "--privileged"], + "mounts": ["source=dind-var-lib-docker,target=/var/lib/docker,type=volume"], + "overrideCommand": false, + + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "node", + + // Use this environment variable if you need to bind mount your local source code into a new container. + // Set *default* container specific settings.json values on container create. + "settings": { + "terminal.integrated.shell.linux": "/bin/bash" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-azuretools.vscode-docker", + "GitHub.vscode-pull-request-github", + + // linting & formatting + "ms-vscode.vscode-typescript-tslint-plugin", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "stylelint.vscode-stylelint", + + // highlighting + "blanu.vscode-styled-jsx", + "tobermory.es6-string-html", + "apollographql.vscode-apollo", + "editorconfig.editorconfig", + + // styling + "coenraads.bracket-pair-colorizer", + "ricard.postcss", + "wayou.vscode-todo-highlight", + "mikestead.dotenv", + "naumovs.color-highlight", + "oderwat.indent-rainbow", + + // themes + "github.github-vscode-theme", + "linusu.auto-dark-mode", + "qinjia.seti-icons", + + // info + "wix.vscode-import-cost" + ], + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [3000, 4000, 4569, 6379], + + // Use 'postCreateCommand' to run commands after the container is created. + // "oryx build" will automatically install your dependencies and attempt to build your project + "postCreateCommand": "oryx build -p virtualenv_name=.venv --log-file /tmp/oryx-build.log || echo 'Could not auto-build. Skipping.'", + + "containerEnv": { + "EDITOR": "vim", + "VISUAL": "vim" + } +} diff --git a/.devcontainer/first-run-notice.txt b/.devcontainer/first-run-notice.txt new file mode 100644 index 0000000000..dd3daab319 --- /dev/null +++ b/.devcontainer/first-run-notice.txt @@ -0,0 +1,15 @@ + __ __ _ _ + | \/ | __ _| |_| |_ ___ _ __ ___ + | |\/| |/ _` | __| __/ _ \ '__/ __| + | | | | (_| | |_| || __/ | \__ \ + |_| |_|\__,_|\__|\__\___|_| |___/ + + +👋 Welcome to the dev environment for building the Matters community and tools! + +👀 Read the README.md before your start building. + +🔧 More developer resources: https://github.com/thematters/developer-resource + +🌍 Visit Matters Community: https://matters.news + diff --git a/.devcontainer/library-scripts/common-debian.sh b/.devcontainer/library-scripts/common-debian.sh new file mode 100644 index 0000000000..32d58fe2be --- /dev/null +++ b/.devcontainer/library-scripts/common-debian.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/microsoft/vscode-dev-containers/blob/master/script-library/docs/common.md +# Maintainer: The VS Code and Codespaces Teams +# +# Syntax: ./common-debian.sh [install zsh flag] [username] [user UID] [user GID] [upgrade packages flag] [install Oh My Zsh! flag] [Add non-free packages] + +INSTALL_ZSH=${1:-"true"} +USERNAME=${2:-"automatic"} +USER_UID=${3:-"automatic"} +USER_GID=${4:-"automatic"} +UPGRADE_PACKAGES=${5:-"true"} +INSTALL_OH_MYS=${6:-"true"} +ADD_NON_FREE_PACKAGES=${7:-"false"} + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Ensure that login shells get the correct path if the user updated the PATH using ENV. +rm -f /etc/profile.d/00-restore-env.sh +echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh +chmod +x /etc/profile.d/00-restore-env.sh + +# If in automatic mode, determine if a user already exists, if not use vscode +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in ${POSSIBLE_USERS[@]}; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=vscode + fi +elif [ "${USERNAME}" = "none" ]; then + USERNAME=root + USER_UID=0 + USER_GID=0 +fi + +# Load markers to see which steps have already run +MARKER_FILE="/usr/local/etc/vscode-dev-containers/common" +if [ -f "${MARKER_FILE}" ]; then + echo "Marker file found:" + cat "${MARKER_FILE}" + source "${MARKER_FILE}" +fi + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +# Function to call apt-get if needed +apt-get-update-if-needed() +{ + if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update + else + echo "Skipping apt-get update." + fi +} + +# Run install apt-utils to avoid debconf warning then verify presence of other common developer tools and dependencies +if [ "${PACKAGES_ALREADY_INSTALLED}" != "true" ]; then + + PACKAGE_LIST="apt-utils \ + git \ + openssh-client \ + gnupg2 \ + iproute2 \ + procps \ + lsof \ + htop \ + net-tools \ + psmisc \ + curl \ + wget \ + rsync \ + ca-certificates \ + unzip \ + zip \ + nano \ + vim-tiny \ + less \ + jq \ + lsb-release \ + apt-transport-https \ + dialog \ + libc6 \ + libgcc1 \ + libkrb5-3 \ + libgssapi-krb5-2 \ + libicu[0-9][0-9] \ + liblttng-ust0 \ + libstdc++6 \ + zlib1g \ + locales \ + sudo \ + ncdu \ + man-db \ + strace \ + manpages \ + manpages-dev " + + # Needed for adding manpages-posix and manpages-posix-dev which are non-free packages in Debian + if [ "${ADD_NON_FREE_PACKAGES}" = "true" ]; then + CODENAME="$(cat /etc/os-release | grep -oE '^VERSION_CODENAME=.+$' | cut -d'=' -f2)" + sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${CODENAME} main/deb http:\/\/deb\.debian\.org\/debian ${CODENAME} main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${CODENAME} main/deb http:\/\/deb\.debian\.org\/debian ${CODENAME} main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${CODENAME}-updates main/deb http:\/\/deb\.debian\.org\/debian ${CODENAME}-updates main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${CODENAME}-updates main/deb http:\/\/deb\.debian\.org\/debian ${CODENAME}-updates main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${CODENAME}-backports main contrib non-free/" /etc/apt/sources.list + sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${CODENAME}-backports main contrib non-free/" /etc/apt/sources.list + echo "Running apt-get update..." + apt-get update + PACKAGE_LIST="${PACKAGE_LIST} manpages-posix manpages-posix-dev" + else + apt-get-update-if-needed + fi + + # Install libssl1.1 if available + if [[ ! -z $(apt-cache --names-only search ^libssl1.1$) ]]; then + PACKAGE_LIST="${PACKAGE_LIST} libssl1.1" + fi + + # Install appropriate version of libssl1.0.x if available + LIBSSL=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1 || echo '') + if [ "$(echo "$LIBSSL" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then + if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then + # Debian 9 + PACKAGE_LIST="${PACKAGE_LIST} libssl1.0.2" + elif [[ ! -z $(apt-cache --names-only search ^libssl1.0.0$) ]]; then + # Ubuntu 18.04, 16.04, earlier + PACKAGE_LIST="${PACKAGE_LIST} libssl1.0.0" + fi + fi + + echo "Packages to verify are installed: ${PACKAGE_LIST}" + apt-get -y install --no-install-recommends ${PACKAGE_LIST} 2> >( grep -v 'debconf: delaying package configuration, since apt-utils is not installed' >&2 ) + + PACKAGES_ALREADY_INSTALLED="true" +fi + +# Get to latest versions of all packages +if [ "${UPGRADE_PACKAGES}" = "true" ]; then + apt-get-update-if-needed + apt-get -y upgrade --no-install-recommends + apt-get autoremove -y +fi + +# Ensure at least the en_US.UTF-8 UTF-8 locale is available. +# Common need for both applications and things like the agnoster ZSH theme. +if [ "${LOCALE_ALREADY_SET}" != "true" ] && ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen + locale-gen + LOCALE_ALREADY_SET="true" +fi + +# Create or update a non-root user to match UID/GID. +if id -u ${USERNAME} > /dev/null 2>&1; then + # User exists, update if needed + if [ "${USER_GID}" != "automatic" ] && [ "$USER_GID" != "$(id -G $USERNAME)" ]; then + groupmod --gid $USER_GID $USERNAME + usermod --gid $USER_GID $USERNAME + fi + if [ "${USER_UID}" != "automatic" ] && [ "$USER_UID" != "$(id -u $USERNAME)" ]; then + usermod --uid $USER_UID $USERNAME + fi +else + # Create user + if [ "${USER_GID}" = "automatic" ]; then + groupadd $USERNAME + else + groupadd --gid $USER_GID $USERNAME + fi + if [ "${USER_UID}" = "automatic" ]; then + useradd -s /bin/bash --gid $USERNAME -m $USERNAME + else + useradd -s /bin/bash --uid $USER_UID --gid $USERNAME -m $USERNAME + fi +fi + +# Add add sudo support for non-root user +if [ "${USERNAME}" != "root" ] && [ "${EXISTING_NON_ROOT_USER}" != "${USERNAME}" ]; then + echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME + chmod 0440 /etc/sudoers.d/$USERNAME + EXISTING_NON_ROOT_USER="${USERNAME}" +fi + +# ** Shell customization section ** +if [ "${USERNAME}" = "root" ]; then + USER_RC_PATH="/root" +else + USER_RC_PATH="/home/${USERNAME}" +fi + +# .bashrc/.zshrc snippet +RC_SNIPPET="$(cat << 'EOF' +if [ -z "${USER}" ]; then export USER=$(whoami); fi +if [[ "${PATH}" != *"$HOME/.local/bin"* ]]; then export PATH="${PATH}:$HOME/.local/bin"; fi +# Display optional first run image specific notice if configured and terminal is interactive +if [ -t 1 ] && [ ! -f "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed" ]; then + if [ -f "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" ]; then + cat "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" + elif [ -f "/workspaces/.codespaces/shared/first-run-notice.txt" ]; then + cat "/workspaces/.codespaces/shared/first-run-notice.txt" + fi + mkdir -p "$HOME/.config/vscode-dev-containers" + # Mark first run notice as displayed after 10s to avoid problems with fast terminal refreshes hiding it + ((sleep 10s; touch "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed") &) +fi +EOF +)" + +# code shim, it fallbacks to code-insiders if code is not available +cat << 'EOF' > /usr/local/bin/code +#!/bin/sh +get_in_path_except_current() { + which -a "$1" | grep -A1 "$0" | grep -v "$0" +} +code="$(get_in_path_except_current code)" +if [ -n "$code" ]; then + exec "$code" "$@" +elif [ "$(command -v code-insiders)" ]; then + exec code-insiders "$@" +else + echo "code or code-insiders is not installed" >&2 + exit 127 +fi +EOF +chmod +x /usr/local/bin/code + +# Codespaces bash and OMZ themes - partly inspired by https://github.com/ohmyzsh/ohmyzsh/blob/master/themes/robbyrussell.zsh-theme +CODESPACES_BASH="$(cat \ +<<'EOF' +# Codespaces bash prompt theme +__bash_prompt() { + local userpart='`export XIT=$? \ + && [ ! -z "${GITHUB_USER}" ] && echo -n "\[\033[0;32m\]@${GITHUB_USER} " || echo -n "\[\033[0;32m\]\u " \ + && [ "$XIT" -ne "0" ] && echo -n "\[\033[1;31m\]➜" || echo -n "\[\033[0m\]➜"`' + local gitbranch='`\ + export BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); \ + if [ "${BRANCH}" = "HEAD" ]; then \ + export BRANCH=$(git describe --contains --all HEAD 2>/dev/null); \ + fi; \ + if [ "${BRANCH}" != "" ]; then \ + echo -n "\[\033[0;36m\](\[\033[1;31m\]${BRANCH}" \ + && if git ls-files --error-unmatch -m --directory --no-empty-directory -o --exclude-standard ":/*" > /dev/null 2>&1; then \ + echo -n " \[\033[1;33m\]✗"; \ + fi \ + && echo -n "\[\033[0;36m\]) "; \ + fi`' + local lightblue='\[\033[1;34m\]' + local removecolor='\[\033[0m\]' + PS1="${userpart} ${lightblue}\w ${gitbranch}${removecolor}\$ " + unset -f __bash_prompt +} +__bash_prompt +EOF +)" +CODESPACES_ZSH="$(cat \ +<<'EOF' +__zsh_prompt() { + local prompt_username + if [ ! -z "${GITHUB_USER}" ]; then + prompt_username="@${GITHUB_USER}" + else + prompt_username="%n" + fi + PROMPT="%{$fg[green]%}${prompt_username} %(?:%{$reset_color%}➜ :%{$fg_bold[red]%}➜ )" # User/exit code arrow + PROMPT+='%{$fg_bold[blue]%}%(5~|%-1~/…/%3~|%4~)%{$reset_color%} ' # cwd + PROMPT+='$(git_prompt_info)%{$fg[white]%}$ %{$reset_color%}' # Git status + unset -f __zsh_prompt +} +ZSH_THEME_GIT_PROMPT_PREFIX="%{$fg_bold[cyan]%}(%{$fg_bold[red]%}" +ZSH_THEME_GIT_PROMPT_SUFFIX="%{$reset_color%} " +ZSH_THEME_GIT_PROMPT_DIRTY=" %{$fg_bold[yellow]%}✗%{$fg_bold[cyan]%})" +ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg_bold[cyan]%})" +__zsh_prompt +EOF +)" + +# Add notice that Oh My Bash! has been removed from images and how to provide information on how to install manually +OMB_README="$(cat \ +<<'EOF' +"Oh My Bash!" has been removed from this image in favor of a simple shell prompt. If you +still wish to use it, remove "~/.oh-my-bash" and install it from: https://github.com/ohmybash/oh-my-bash +You may also want to consider "Bash-it" as an alternative: https://github.com/bash-it/bash-it +See here for infomation on adding it to your image or dotfiles: https://aka.ms/codespaces/omb-remove +EOF +)" +OMB_STUB="$(cat \ +<<'EOF' +#!/usr/bin/env bash +if [ -t 1 ]; then + cat $HOME/.oh-my-bash/README.md +fi +EOF +)" + +# Add RC snippet and custom bash prompt +if [ "${RC_SNIPPET_ALREADY_ADDED}" != "true" ]; then + echo "${RC_SNIPPET}" >> /etc/bash.bashrc + echo "${CODESPACES_BASH}" >> "${USER_RC_PATH}/.bashrc" + echo 'export PROMPT_DIRTRIM=4' >> "${USER_RC_PATH}/.bashrc" + if [ "${USERNAME}" != "root" ]; then + echo "${CODESPACES_BASH}" >> "/root/.bashrc" + echo 'export PROMPT_DIRTRIM=4' >> "/root/.bashrc" + fi + chown ${USERNAME}:${USERNAME} "${USER_RC_PATH}/.bashrc" + RC_SNIPPET_ALREADY_ADDED="true" +fi + +# Add stub for Oh My Bash! +if [ ! -d "${USER_RC_PATH}/.oh-my-bash}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then + mkdir -p "${USER_RC_PATH}/.oh-my-bash" "/root/.oh-my-bash" + echo "${OMB_README}" >> "${USER_RC_PATH}/.oh-my-bash/README.md" + echo "${OMB_STUB}" >> "${USER_RC_PATH}/.oh-my-bash/oh-my-bash.sh" + chmod +x "${USER_RC_PATH}/.oh-my-bash/oh-my-bash.sh" + if [ "${USERNAME}" != "root" ]; then + echo "${OMB_README}" >> "/root/.oh-my-bash/README.md" + echo "${OMB_STUB}" >> "/root/.oh-my-bash/oh-my-bash.sh" + chmod +x "/root/.oh-my-bash/oh-my-bash.sh" + fi + chown -R "${USERNAME}:${USERNAME}" "${USER_RC_PATH}/.oh-my-bash" +fi + +# Optionally install and configure zsh and Oh My Zsh! +if [ "${INSTALL_ZSH}" = "true" ]; then + if ! type zsh > /dev/null 2>&1; then + apt-get-update-if-needed + apt-get install -y zsh + fi + if [ "${ZSH_ALREADY_INSTALLED}" != "true" ]; then + echo "${RC_SNIPPET}" >> /etc/zsh/zshrc + ZSH_ALREADY_INSTALLED="true" + fi + + # Adapted, simplified inline Oh My Zsh! install steps that adds, defaults to a codespaces theme. + # See https://github.com/ohmyzsh/ohmyzsh/blob/master/tools/install.sh for official script. + OH_MY_INSTALL_DIR="${USER_RC_PATH}/.oh-my-zsh" + if [ ! -d "${OH_MY_INSTALL_DIR}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then + TEMPLATE_PATH="${OH_MY_INSTALL_DIR}/templates/zshrc.zsh-template" + USER_RC_FILE="${USER_RC_PATH}/.zshrc" + umask g-w,o-w + mkdir -p ${OH_MY_INSTALL_DIR} + git clone --depth=1 \ + -c core.eol=lf \ + -c core.autocrlf=false \ + -c fsck.zeroPaddedFilemode=ignore \ + -c fetch.fsck.zeroPaddedFilemode=ignore \ + -c receive.fsck.zeroPaddedFilemode=ignore \ + "https://github.com/ohmyzsh/ohmyzsh" "${OH_MY_INSTALL_DIR}" 2>&1 + echo -e "$(cat "${TEMPLATE_PATH}")\nDISABLE_AUTO_UPDATE=true\nDISABLE_UPDATE_PROMPT=true" > ${USER_RC_FILE} + sed -i -e 's/ZSH_THEME=.*/ZSH_THEME="codespaces"/g' ${USER_RC_FILE} + + mkdir -p ${OH_MY_INSTALL_DIR}/custom/themes + echo "${CODESPACES_ZSH}" > "${OH_MY_INSTALL_DIR}/custom/themes/codespaces.zsh-theme" + # Shrink git while still enabling updates + cd "${OH_MY_INSTALL_DIR}" + git repack -a -d -f --depth=1 --window=1 + # Copy to non-root user if one is specified + if [ "${USERNAME}" != "root" ]; then + cp -rf "${USER_RC_FILE}" "${OH_MY_INSTALL_DIR}" /root + chown -R ${USERNAME}:${USERNAME} "${USER_RC_PATH}" + fi + fi +fi + +# Persist image metadata info, script if meta.env found in same directory +META_INFO_SCRIPT="$(cat << 'EOF' +#!/bin/sh +. /usr/local/etc/vscode-dev-containers/meta.env +# Minimal output +if [ "$1" = "version" ] || [ "$1" = "image-version" ]; then + echo "${VERSION}" + exit 0 +elif [ "$1" = "release" ]; then + echo "${GIT_REPOSITORY_RELEASE}" + exit 0 +elif [ "$1" = "content" ] || [ "$1" = "content-url" ] || [ "$1" = "contents" ] || [ "$1" = "contents-url" ]; then + echo "${CONTENTS_URL}" + exit 0 +fi +#Full output +echo +echo "Development container image information" +echo +if [ ! -z "${VERSION}" ]; then echo "- Image version: ${VERSION}"; fi +if [ ! -z "${DEFINITION_ID}" ]; then echo "- Definition ID: ${DEFINITION_ID}"; fi +if [ ! -z "${VARIANT}" ]; then echo "- Variant: ${VARIANT}"; fi +if [ ! -z "${GIT_REPOSITORY}" ]; then echo "- Source code repository: ${GIT_REPOSITORY}"; fi +if [ ! -z "${GIT_REPOSITORY_RELEASE}" ]; then echo "- Source code release/branch: ${GIT_REPOSITORY_RELEASE}"; fi +if [ ! -z "${BUILD_TIMESTAMP}" ]; then echo "- Timestamp: ${BUILD_TIMESTAMP}"; fi +if [ ! -z "${CONTENTS_URL}" ]; then echo && echo "More info: ${CONTENTS_URL}"; fi +echo +EOF +)" +SCRIPT_DIR="$(cd $(dirname $0) && pwd)" +if [ -f "${SCRIPT_DIR}/meta.env" ]; then + mkdir -p /usr/local/etc/vscode-dev-containers/ + cp -f "${SCRIPT_DIR}/meta.env" /usr/local/etc/vscode-dev-containers/meta.env + echo "${META_INFO_SCRIPT}" > /usr/local/bin/devcontainer-info + chmod +x /usr/local/bin/devcontainer-info +fi + +# Write marker file +mkdir -p "$(dirname "${MARKER_FILE}")" +echo -e "\ + PACKAGES_ALREADY_INSTALLED=${PACKAGES_ALREADY_INSTALLED}\n\ + LOCALE_ALREADY_SET=${LOCALE_ALREADY_SET}\n\ + EXISTING_NON_ROOT_USER=${EXISTING_NON_ROOT_USER}\n\ + RC_SNIPPET_ALREADY_ADDED=${RC_SNIPPET_ALREADY_ADDED}\n\ + ZSH_ALREADY_INSTALLED=${ZSH_ALREADY_INSTALLED}" > "${MARKER_FILE}" + +echo "Done!" diff --git a/.devcontainer/library-scripts/docker-in-docker-debian.sh b/.devcontainer/library-scripts/docker-in-docker-debian.sh new file mode 100644 index 0000000000..c75ac4a1b8 --- /dev/null +++ b/.devcontainer/library-scripts/docker-in-docker-debian.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/microsoft/vscode-dev-containers/blob/master/script-library/docs/docker-in-docker.md +# Maintainer: The VS Code and Codespaces Teams +# +# Syntax: ./docker-in-docker-debian.sh [enable non-root docker access flag] [non-root user] [use moby] + +ENABLE_NONROOT_DOCKER=${1:-"true"} +USERNAME=${2:-"automatic"} +USE_MOBY=${3:-"true"} + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in ${POSSIBLE_USERS[@]}; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi + +# Function to run apt-get if needed +apt-get-update-if-needed() +{ + if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update + else + echo "Skipping apt-get update." + fi +} + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +# Install docker/dockerd dependencies if missing +if ! dpkg -s apt-transport-https curl ca-certificates lsb-release lxc pigz iptables > /dev/null 2>&1 || ! type gpg > /dev/null 2>&1; then + apt-get-update-if-needed + apt-get -y install --no-install-recommends apt-transport-https curl ca-certificates lsb-release lxc pigz iptables gnupg2 +fi + +# Swap to legacy iptables for compatibility +if type iptables-legacy > /dev/null 2>&1; then + update-alternatives --set iptables /usr/sbin/iptables-legacy + update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy +fi + +# Install Docker / Moby CLI if not already installed +if type docker > /dev/null 2>&1 && type dockerd > /dev/null 2>&1; then + echo "Docker / Moby CLI and Engine already installed." +else + if [ "${USE_MOBY}" = "true" ]; then + DISTRO=$(lsb_release -is | tr '[:upper:]' '[:lower:]') + CODENAME=$(lsb_release -cs) + curl -s https://packages.microsoft.com/keys/microsoft.asc | (OUT=$(apt-key add - 2>&1) || echo $OUT) + echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-${DISTRO}-${CODENAME}-prod ${CODENAME} main" > /etc/apt/sources.list.d/microsoft.list + apt-get update + apt-get -y install --no-install-recommends moby-cli moby-buildx moby-engine + else + curl -fsSL https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/gpg | (OUT=$(apt-key add - 2>&1) || echo $OUT) + echo "deb [arch=amd64] https://download.docker.com/linux/$(lsb_release -is | tr '[:upper:]' '[:lower:]') $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list + apt-get update + apt-get -y install --no-install-recommends docker-ce-cli docker-ce + fi +fi + +echo "Finished installing docker / moby" + +# Install Docker Compose if not already installed +if type docker-compose > /dev/null 2>&1; then + echo "Docker Compose already installed." +else + LATEST_COMPOSE_VERSION=$(basename "$(curl -fsSL -o /dev/null -w "%{url_effective}" https://github.com/docker/compose/releases/latest)") + curl -fsSL "https://github.com/docker/compose/releases/download/${LATEST_COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose +fi + +# If init file already exists, exit +if [ -f "/usr/local/share/docker-init.sh" ]; then + echo "/usr/local/share/docker-init.sh already exists, so exiting." + exit 0 +fi +echo "docker-init doesnt exist..." + +# Add user to the docker group +if [ "${ENABLE_NONROOT_DOCKER}" = "true" ]; then + if ! getent group docker > /dev/null 2>&1; then + groupadd docker + fi + usermod -aG docker ${USERNAME} +fi + +tee /usr/local/share/docker-init.sh > /dev/null \ +<< 'EOF' +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +sudoIf() +{ + if [ "$(id -u)" -ne 0 ]; then + sudo "$@" + else + "$@" + fi +} +# explicitly remove dockerd and containerd PID file to ensure that it can start properly if it was stopped uncleanly +# ie: docker kill +sudoIf find /run /var/run -iname 'docker*.pid' -delete || : +sudoIf find /run /var/run -iname 'container*.pid' -delete || : +set -e +## Dind wrapper script from docker team +# Maintained: https://github.com/moby/moby/blob/master/hack/dind +export container=docker +if [ -d /sys/kernel/security ] && ! sudoIf mountpoint -q /sys/kernel/security; then + sudoIf mount -t securityfs none /sys/kernel/security || { + echo >&2 'Could not mount /sys/kernel/security.' + echo >&2 'AppArmor detection and --privileged mode might break.' + } +fi +# Mount /tmp (conditionally) +if ! sudoIf mountpoint -q /tmp; then + sudoIf mount -t tmpfs none /tmp +fi +# cgroup v2: enable nesting +if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + # move the init process (PID 1) from the root group to the /init group, + # otherwise writing subtree_control fails with EBUSY. + sudoIf mkdir -p /sys/fs/cgroup/init + sudoIf echo 1 > /sys/fs/cgroup/init/cgroup.procs + # enable controllers + sudoIf sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \ + > /sys/fs/cgroup/cgroup.subtree_control +fi +## Dind wrapper over. +# Handle DNS +set +e +cat /etc/resolv.conf | grep -i 'internal.cloudapp.net' +if [ $? -eq 0 ] +then + echo "Setting dockerd Azure DNS." + CUSTOMDNS="--dns 168.63.129.16" +else + echo "Not setting dockerd DNS manually." + CUSTOMDNS="" +fi +set -e +# Start docker/moby engine +( sudoIf dockerd $CUSTOMDNS > /tmp/dockerd.log 2>&1 ) & +set +e +# Execute whatever commands were passed in (if any). This allows us +# to set this script to ENTRYPOINT while still executing the default CMD. +exec "$@" +EOF + +chmod +x /usr/local/share/docker-init.sh +chown ${USERNAME}:root /usr/local/share/docker-init.sh diff --git a/.devcontainer/library-scripts/meta.env b/.devcontainer/library-scripts/meta.env new file mode 100644 index 0000000000..9e5433682e --- /dev/null +++ b/.devcontainer/library-scripts/meta.env @@ -0,0 +1 @@ +VERSION='dev' diff --git a/.devcontainer/library-scripts/node-debian.sh b/.devcontainer/library-scripts/node-debian.sh new file mode 100644 index 0000000000..3c6ca7f05f --- /dev/null +++ b/.devcontainer/library-scripts/node-debian.sh @@ -0,0 +1,122 @@ +#!/bin/bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +# Docs: https://github.com/microsoft/vscode-dev-containers/blob/master/script-library/docs/node.md +# Maintainer: The VS Code and Codespaces Teams +# +# Syntax: ./node-debian.sh [directory to install nvm] [node version to install (use "none" to skip)] [non-root user] [Update rc files flag] + +export NVM_DIR=${1:-"/usr/local/share/nvm"} +export NODE_VERSION=${2:-"lts/*"} +USERNAME=${3:-"automatic"} +UPDATE_RC=${4:-"true"} + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Ensure that login shells get the correct path if the user updated the PATH using ENV. +rm -f /etc/profile.d/00-restore-env.sh +echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh +chmod +x /etc/profile.d/00-restore-env.sh + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in ${POSSIBLE_USERS[@]}; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi + +if [ "${NODE_VERSION}" = "none" ]; then + export NODE_VERSION= +fi + +function updaterc() { + if [ "${UPDATE_RC}" = "true" ]; then + echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..." + echo -e "$1" >> /etc/bash.bashrc + if [ -f "/etc/zsh/zshrc" ]; then + echo -e "$1" >> /etc/zsh/zshrc + fi + fi +} + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +# Install curl, apt-transport-https, tar, or gpg if missing +if ! dpkg -s apt-transport-https curl ca-certificates tar > /dev/null 2>&1 || ! type gpg > /dev/null 2>&1; then + if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then + apt-get update + fi + apt-get -y install --no-install-recommends apt-transport-https curl ca-certificates tar gnupg2 +fi + +# Install yarn +if type yarn > /dev/null 2>&1; then + echo "Yarn already installed." +else + curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | (OUT=$(apt-key add - 2>&1) || echo $OUT) + echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list + apt-get update + apt-get -y install --no-install-recommends yarn +fi + +# Install the specified node version if NVM directory already exists, then exit +if [ -d "${NVM_DIR}" ]; then + echo "NVM already installed." + if [ "${NODE_VERSION}" != "" ]; then + su ${USERNAME} -c ". $NVM_DIR/nvm.sh && nvm install ${NODE_VERSION} && nvm clear-cache" + fi + exit 0 +fi + +# Create nvm group, nvm dir, and set sticky bit +if ! cat /etc/group | grep -e "^nvm:" > /dev/null 2>&1; then + groupadd -r nvm +fi +umask 0002 +usermod -a -G nvm ${USERNAME} +mkdir -p ${NVM_DIR} +chown :nvm ${NVM_DIR} +chmod g+s ${NVM_DIR} +su ${USERNAME} -c "$(cat << EOF + set -e + umask 0002 + # Do not update profile - we'll do this manually + export PROFILE=/dev/null + curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash + source ${NVM_DIR}/nvm.sh + if [ "${NODE_VERSION}" != "" ]; then + nvm alias default ${NODE_VERSION} + fi + nvm clear-cache +EOF +)" 2>&1 +# Update rc files +if [ "${UPDATE_RC}" = "true" ]; then +updaterc "$(cat < /etc/profile.d/00-restore-env.sh +chmod +x /etc/profile.d/00-restore-env.sh + +# Determine the appropriate non-root user +if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then + USERNAME="" + POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") + for CURRENT_USER in ${POSSIBLE_USERS[@]}; do + if id -u ${CURRENT_USER} > /dev/null 2>&1; then + USERNAME=${CURRENT_USER} + break + fi + done + if [ "${USERNAME}" = "" ]; then + USERNAME=root + fi +elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then + USERNAME=root +fi + +# Function to run apt-get if needed +apt-get-update-if-needed() +{ + if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update + else + echo "Skipping apt-get update." + fi +} + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +# Install openssh-server openssh-client +if ! dpkg -s openssh-server openssh-client > /dev/null 2>&1; then + apt-get-update-if-needed + apt-get -y install --no-install-recommends openssh-server openssh-client +fi + +# Generate password if new password set to the word "random" +if [ "${NEW_PASSWORD}" = "random" ]; then + NEW_PASSWORD="$(openssl rand -hex 16)" + EMIT_PASSWORD="true" +fi + +# If new password not set to skip, set it for the specified user +if [ "${NEW_PASSWORD}" != "skip" ]; then + echo "${USERNAME}:${NEW_PASSWORD}" | chpasswd + if [ "${NEW_PASSWORD}" != "root" ]; then + usermod -aG ssh ${USERNAME} + fi +fi + +# Setup sshd +mkdir -p /var/run/sshd +sed -i 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' /etc/pam.d/sshd +sed -i 's/#*PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config +sed -i -E "s/#*\s*Port\s+.+/Port ${SSHD_PORT}/g" /etc/ssh/sshd_config + +# Write out a script that can be referenced as an ENTRYPOINT to auto-start sshd +tee /usr/local/share/ssh-init.sh > /dev/null \ +<< EOF +#!/usr/bin/env bash +set -e +if [ "\$(id -u)" -ne 0 ]; then + sudo /etc/init.d/ssh start > /tmp/sshd.log 2>&1 +else + /etc/init.d/ssh start > /tmp/sshd.log 2>&1 +fi +set +e +exec "\$@" +EOF +chmod +x /usr/local/share/ssh-init.sh +chown ${USERNAME}:ssh /usr/local/share/ssh-init.sh + +# If we should start sshd now, do so +if [ "${START_SSHD}" = "true" ]; then + /usr/local/share/ssh-init.sh +fi + +# Write out result +echo -e "Done!\n\n- Port: ${SSHD_PORT}\n- User: ${USERNAME}" +if [ "${EMIT_PASSWORD}" = "true" ]; then + echo "- Password: ${NEW_PASSWORD}" +fi +echo -e "\nForward port ${SSHD_PORT} to your local machine and run:\n\n ssh -p ${SSHD_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${USERNAME}@localhost\n" diff --git a/.devcontainer/setup-user.sh b/.devcontainer/setup-user.sh new file mode 100644 index 0000000000..9cc94671f4 --- /dev/null +++ b/.devcontainer/setup-user.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +USERNAME=${1:-codespace} +SECURE_PATH_BASE=${2:-$PATH} + +echo "Defaults secure_path=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin:${SECURE_PATH_BASE}\"" >> /etc/sudoers.d/securepath + +# Install and setup fish +apt-get install -yq fish +FISH_PROMPT="function fish_prompt\n set_color green\n echo -n (whoami)\n set_color normal\n echo -n \":\"\n set_color blue\n echo -n (pwd)\n set_color normal\n echo -n \"> \"\nend\n" +printf "$FISH_PROMPT" >> /etc/fish/functions/fish_prompt.fish +printf "if type code-insiders > /dev/null 2>&1 and ! type code > /dev/null 2>&1\n alias code=code-insiders\nend" >> /etc/fish/conf.d/code_alias.fish + +# Add user to a Docker group +sudo -u ${USERNAME} mkdir /home/${USERNAME}/.vsonline +groupadd -g 800 docker +usermod -a -G docker ${USERNAME} + +# Set VS Code as user's git edtior +tee /tmp/scripts/git-ed.sh > /dev/null << EOF +#!/usr/bin/env bash +if [[ \$(which code-insiders) && ! \$(which code) ]]; then + GIT_ED="code-insiders" +else + GIT_ED="code" +fi +\$GIT_ED --wait \$@ +EOF + +sudo -u ${USERNAME} mkdir -p /home/${USERNAME}/.local/bin +install -o ${USERNAME} -g ${USERNAME} -m 755 /tmp/scripts/git-ed.sh /home/${USERNAME}/.local/bin/git-ed.sh +sudo -u ${USERNAME} git config --global core.editor "/home/${USERNAME}/.local/bin/git-ed.sh" +rm -f /tmp/scripts/git-ed.sh diff --git a/package-lock.json b/package-lock.json index 71d9390255..52e3ece80c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "matters-web", - "version": "3.24.0", + "version": "3.25.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d11280a577..2ccaf483be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matters-web", - "version": "3.24.0", + "version": "3.25.0", "description": "codebase of Matters' website", "sideEffects": false, "author": "Matters ", diff --git a/src/common/enums/events.ts b/src/common/enums/events.ts index 29dd7c2fc1..c2350d5873 100644 --- a/src/common/enums/events.ts +++ b/src/common/enums/events.ts @@ -14,7 +14,7 @@ export const REFETCH_TAG_DETAIL_ARTICLES = 'refetchTagDetailArticles' // Circle export const REFETCH_CIRCLE_DETAIL_ARTICLES = 'refetchCircleDetailArticles' export const REFETCH_CIRCLE_DETAIL = 'refetchCircleDetail' -export const REFETCH_CIRCLE_INVITATIONS = 'refetchCircleInvitations' +export const REFETCH_CIRCLE_PENDING_INVITES = 'refetchCirclePendingInvites' // Donators export const REFETCH_DONATORS = 'refetchDonators' diff --git a/src/common/enums/text.ts b/src/common/enums/text.ts index 71f1bb013d..b6993aff38 100644 --- a/src/common/enums/text.ts +++ b/src/common/enums/text.ts @@ -70,6 +70,7 @@ export const TEXT = { cover: '封面', create: '創建', createTag: '新增標籤', + days: '天', delete: '刪除', deleteArticleTag: '作品已移除標籤', deleteDraft: '刪除草稿', @@ -393,6 +394,7 @@ export const TEXT = { cover: '封面', create: '创建', createTag: '新建标签', + days: '天', delete: '删除', deleteArticleTag: '作品已移除标签', deleteDraft: '刪除草稿', @@ -721,6 +723,7 @@ export const TEXT = { cover: 'Cover', create: 'Create', createTag: 'Create Tags', + days: 'days', delete: 'Delete', deleteArticleTag: 'Tag removed', deleteComment: 'Delete', diff --git a/src/components/CircleDigest/Price/index.tsx b/src/components/CircleDigest/Price/index.tsx index ee901a429e..e888cd1316 100644 --- a/src/components/CircleDigest/Price/index.tsx +++ b/src/components/CircleDigest/Price/index.tsx @@ -39,7 +39,7 @@ const fragments = { isMember invitedBy { id - accepted + state freePeriod } } diff --git a/src/components/CircleInvitation/Period.tsx b/src/components/CircleInvitation/Period.tsx new file mode 100644 index 0000000000..ec4a1b4647 --- /dev/null +++ b/src/components/CircleInvitation/Period.tsx @@ -0,0 +1,61 @@ +import classNames from 'classnames' +import differenceInDays from 'date-fns/differenceInDays' +import parseISO from 'date-fns/parseISO' + +import { Translate } from '~/components' + +import styles from './styles.css' + +import { CircleInvitation } from './__generated__/CircleInvitation' + +type PeriodProps = Pick + +/** + * CircleInvitationPeriod is a component for displaying free period in days. + * + * Usage: + * + * ```tsx + * + * ``` + */ +const Period = ({ freePeriod, acceptedAt, state }: PeriodProps) => { + const isPending = state === 'pending' + const isAccepted = state === 'accepted' + + const classes = classNames({ + period: true, + 'margin-right': isPending, + }) + + if (isPending) { + return ( + + {freePeriod} + + + ) + } + + if (isAccepted && acceptedAt) { + const date = parseISO(acceptedAt) + const diffDays = differenceInDays(new Date(), date) || 1 + const remainDays = freePeriod - diffDays + + return ( + + {' '} + {remainDays} + + + ) + } + + return null +} + +export default Period diff --git a/src/components/CircleInvitation/index.tsx b/src/components/CircleInvitation/index.tsx index 67f612e9c9..9570fd5ead 100644 --- a/src/components/CircleInvitation/index.tsx +++ b/src/components/CircleInvitation/index.tsx @@ -1,8 +1,16 @@ import gql from 'graphql-tag' -import { Card, FreePeriod, UserDigest } from '~/components' +import { + Card, + IconInfo16, + TextIcon, + Tooltip, + Translate, + UserDigest, +} from '~/components' import CircleInvitationInvitee from './Invitee' +import CircleInvitationPeriod from './Period' import CircleInvitationResendButton from './Resend' import styles from './styles.css' @@ -12,6 +20,31 @@ interface CircleInvitationProps { invitation: CircleInvitationType } +const CircleInvitationFailedInfo = () => ( + + } + placement="left" + > + + } + color="grey" + size="xs" + spacing="xxxtight" + textPlacement="left" + > + + + + +) + /** * This component is for representing Circle invitation, and it shows: * @@ -28,7 +61,7 @@ interface CircleInvitationProps { * ``` */ export const CircleInvitation = ({ invitation }: CircleInvitationProps) => { - const { circle, freePeriod, invitee } = invitation + const { circle, freePeriod, invitee, acceptedAt, state } = invitation if (!invitee) { return null @@ -41,19 +74,40 @@ export const CircleInvitation = ({ invitation }: CircleInvitationProps) => { }, ] + const isPending = state === 'pending' + const isFailed = state === 'transfer_failed' + const isSucceeded = state === 'transfer_succeeded' + return (
- - - - + + {isPending && ( + + )} + + {isFailed && } + + {isSucceeded && ( + + + + )}
@@ -80,6 +134,8 @@ CircleInvitation.fragments = { } __typename } + acceptedAt + state } ${UserDigest.Mini.fragments.user} `, diff --git a/src/components/CircleInvitation/styles.css b/src/components/CircleInvitation/styles.css index be55bc2796..24a3a2d2d5 100644 --- a/src/components/CircleInvitation/styles.css +++ b/src/components/CircleInvitation/styles.css @@ -20,9 +20,19 @@ height: 100%; padding-right: var(--spacing-base); - & .period { - margin-right: var(--spacing-base); + & span { font-size: var(--font-size-xs); + } + + & .period { color: var(--color-grey-darker); } + + & .succeeded { + color: var(--color-matters-gold); + } + + & .margin-right { + margin-right: var(--spacing-base); + } } diff --git a/src/components/Forms/PaymentForm/SubscribeCircle/Head.tsx b/src/components/Forms/PaymentForm/SubscribeCircle/Head.tsx index d9a152829f..572dd74fd0 100644 --- a/src/components/Forms/PaymentForm/SubscribeCircle/Head.tsx +++ b/src/components/Forms/PaymentForm/SubscribeCircle/Head.tsx @@ -9,6 +9,7 @@ import { ReactComponent as IconCircleFeatureReading } from '@/public/static/icon import ConfirmTable from '../ConfirmTable' import styles from './styles.css' +import { InvitationState } from '@/__generated__/globalTypes' import { DigestRichCirclePrivate } from '~/components/CircleDigest/Rich/__generated__/DigestRichCirclePrivate' import { DigestRichCirclePublic } from '~/components/CircleDigest/Rich/__generated__/DigestRichCirclePublic' @@ -24,7 +25,7 @@ const Head: React.FC = ({ circle }) => { return null } - const isInvited = invitation && invitation.accepted === false + const isInvited = invitation && invitation.state === InvitationState.pending return (
diff --git a/src/components/GQL/queries/circleAcceptedInvites.ts b/src/components/GQL/queries/circleAcceptedInvites.ts new file mode 100644 index 0000000000..7bd63a7bf9 --- /dev/null +++ b/src/components/GQL/queries/circleAcceptedInvites.ts @@ -0,0 +1,31 @@ +import gql from 'graphql-tag' + +import { CircleInvitation } from '~/components' + +export default gql` + query CircleAcceptedInvites($name: String!, $after: String) { + circle(input: { name: $name }) { + id + owner { + id + } + invites { + accepted(input: { first: 20, after: $after }) { + pageInfo { + startCursor + endCursor + hasNextPage + } + edges { + cursor + node { + id + ...CircleInvitation + } + } + } + } + } + } + ${CircleInvitation.fragments.invitation} +` diff --git a/src/components/GQL/queries/circleInvitations.ts b/src/components/GQL/queries/circleInvitations.ts deleted file mode 100644 index 8fb280a9b6..0000000000 --- a/src/components/GQL/queries/circleInvitations.ts +++ /dev/null @@ -1,29 +0,0 @@ -import gql from 'graphql-tag' - -import { CircleInvitation } from '~/components' - -export default gql` - query CircleInvitations($name: String!, $after: String) { - circle(input: { name: $name }) { - id - owner { - id - } - invitations(input: { first: 20, after: $after }) { - pageInfo { - startCursor - endCursor - hasNextPage - } - edges { - cursor - node { - id - ...CircleInvitation - } - } - } - } - } - ${CircleInvitation.fragments.invitation} -` diff --git a/src/components/GQL/queries/circlePendingInvites.ts b/src/components/GQL/queries/circlePendingInvites.ts new file mode 100644 index 0000000000..0aed2859eb --- /dev/null +++ b/src/components/GQL/queries/circlePendingInvites.ts @@ -0,0 +1,31 @@ +import gql from 'graphql-tag' + +import { CircleInvitation } from '~/components' + +export default gql` + query CirclePendingInvites($name: String!, $after: String) { + circle(input: { name: $name }) { + id + owner { + id + } + invites { + pending(input: { first: 20, after: $after }) { + pageInfo { + startCursor + endCursor + hasNextPage + } + edges { + cursor + node { + id + ...CircleInvitation + } + } + } + } + } + } + ${CircleInvitation.fragments.invitation} +` diff --git a/src/stories/mocks/index.ts b/src/stories/mocks/index.ts index 0bb24f9354..84eccd9394 100644 --- a/src/stories/mocks/index.ts +++ b/src/stories/mocks/index.ts @@ -58,8 +58,8 @@ export const MOCK_CIRCLE = { invitedBy: { __typename: 'Invitation' as any, id: 'circle-invitation-000', - accepted: false, - freePeriod: 3, + state: 'pending' as any, + freePeriod: 30, }, } diff --git a/src/views/ArticleDetail/AppreciationButton/gql.ts b/src/views/ArticleDetail/AppreciationButton/gql.ts index 4ef5e618bc..1016a0b1de 100644 --- a/src/views/ArticleDetail/AppreciationButton/gql.ts +++ b/src/views/ArticleDetail/AppreciationButton/gql.ts @@ -30,7 +30,7 @@ export const fragments = { export const APPRECIATE_ARTICLE = gql` mutation AppreciateArticle( $id: ID! - $amount: Int! + $amount: PositiveInt! $token: String! $superLike: Boolean ) { diff --git a/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Option.tsx b/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Option.tsx index b53a3d7d23..8f659b6d52 100644 --- a/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Option.tsx +++ b/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Option.tsx @@ -1,11 +1,4 @@ -import { - DropdownDialog, - Form, - FreePeriod, - Menu, - TextIcon, - Translate, -} from '~/components' +import { DropdownDialog, Form, Menu, TextIcon, Translate } from '~/components' import { Z_INDEX } from '~/common/enums' @@ -14,7 +7,7 @@ interface Props { onClick: (period: number) => void } -const options = [1, 3, 6, 12] +const options = [30, 90, 180, 360] const PeriodOptionTitle = ( - + {option} ))} @@ -82,7 +75,11 @@ const PeriodOption = ({ period, onClick }: Props) => ( > {({ open, ref }) => ( } + title={ + <> + {period} + + } onClick={open} ref={ref} /> diff --git a/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx b/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx index 63224dd41b..39b01603cd 100644 --- a/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx +++ b/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/PreSend.tsx @@ -16,7 +16,7 @@ import { import INVITE_CIRCLE from '~/components/GQL/mutations/invite' import { StagingNode } from '~/components/SearchSelect/StagingArea' -import { REFETCH_CIRCLE_INVITATIONS } from '~/common/enums' +import { REFETCH_CIRCLE_PENDING_INVITES } from '~/common/enums' import { INVITATIONS_CIRCLE } from './gql' import PeriodOption from './Option' @@ -43,7 +43,7 @@ const BaseInviteePreSend = ({ close, confirm, invitees }: Props) => { const { getQuery } = useRoute() const name = getQuery('name') - const [period, setPeriod] = useState(1) + const [period, setPeriod] = useState(30) const [invite, { loading: inviteLoading }] = useMutation( INVITE_CIRCLE ) @@ -97,7 +97,7 @@ const BaseInviteePreSend = ({ close, confirm, invitees }: Props) => { return } - window.dispatchEvent(new CustomEvent(REFETCH_CIRCLE_INVITATIONS)) + window.dispatchEvent(new CustomEvent(REFETCH_CIRCLE_PENDING_INVITES)) confirm() } diff --git a/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Search.tsx b/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Search.tsx index 848acaedf8..da0d9e8248 100644 --- a/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Search.tsx +++ b/src/views/Circle/Settings/ManageInvitation/AddInvitationDialog/Search.tsx @@ -41,6 +41,9 @@ const InviteeSearchEditor = ({ close, save }: Props) => { toStagingArea() } + const selectedNodes = stagingNodes.filter(({ selected }) => !!selected) + const disabled = selectedNodes.length === 0 + return ( <> { closeTextId="cancel" rightButton={ - save({ nodes: stagingNodes.filter(({ selected }) => !!selected) }) - } + disabled={disabled} + onClick={() => save({ nodes: selectedNodes })} text={} /> } diff --git a/src/views/Circle/Settings/ManageInvitation/Invites/Accepted/index.tsx b/src/views/Circle/Settings/ManageInvitation/Invites/Accepted/index.tsx new file mode 100644 index 0000000000..7f69eaffe9 --- /dev/null +++ b/src/views/Circle/Settings/ManageInvitation/Invites/Accepted/index.tsx @@ -0,0 +1,119 @@ +import { useQuery } from '@apollo/react-hooks' +import { useContext } from 'react' + +import { + CircleInvitation, + EmptyWarning, + InfiniteScroll, + List, + QueryError, + Spinner, + Throw404, + Translate, + usePullToRefresh, + useRoute, + ViewerContext, +} from '~/components' +import CIRCLE_ACCEPTED_INVITES from '~/components/GQL/queries/circleAcceptedInvites' + +import { mergeConnections } from '~/common/utils' + +import styles from './styles.css' + +import { CircleAcceptedInvites } from '~/components/GQL/queries/__generated__/CircleAcceptedInvites' + +/** + * This component is for listing circle accepted invitations. + * + * Usage: + * + * ``` + * + * ``` + */ +const AcceptedInvites = () => { + const viewer = useContext(ViewerContext) + const { getQuery } = useRoute() + const name = getQuery('name') + + /** + * Data Fetching + */ + const { + data, + loading, + error, + fetchMore, + refetch, + } = useQuery(CIRCLE_ACCEPTED_INVITES, { + variables: { name }, + }) + + // pagination + const connectionPath = 'circle.invites.accepted' + const circle = data?.circle + const { edges, pageInfo } = circle?.invites.accepted || {} + const isOwner = circle?.owner.id === viewer.id + + // load next page + const loadMore = async () => { + await fetchMore({ + variables: { after: pageInfo?.endCursor }, + updateQuery: (previousResult, { fetchMoreResult }) => + mergeConnections({ + oldData: previousResult, + newData: fetchMoreResult, + path: connectionPath, + }), + }) + } + + usePullToRefresh.Handler(refetch) + + /** + * Render + */ + if (loading) { + return + } + + if (error) { + return + } + + if (!circle || !isOwner || !pageInfo) { + return + } + + if (!edges || edges.length <= 0) { + return ( + + } + /> + ) + } + + return ( +
+ + + {(edges || []).map(({ node, cursor }, i) => ( + + + + ))} + + + + +
+ ) +} + +export default AcceptedInvites diff --git a/src/views/Circle/Settings/ManageInvitation/Invitations/styles.css b/src/views/Circle/Settings/ManageInvitation/Invites/Accepted/styles.css similarity index 100% rename from src/views/Circle/Settings/ManageInvitation/Invitations/styles.css rename to src/views/Circle/Settings/ManageInvitation/Invites/Accepted/styles.css diff --git a/src/views/Circle/Settings/ManageInvitation/Invitations/index.tsx b/src/views/Circle/Settings/ManageInvitation/Invites/Pending/index.tsx similarity index 79% rename from src/views/Circle/Settings/ManageInvitation/Invitations/index.tsx rename to src/views/Circle/Settings/ManageInvitation/Invites/Pending/index.tsx index ea484471fb..ad90fc8b32 100644 --- a/src/views/Circle/Settings/ManageInvitation/Invitations/index.tsx +++ b/src/views/Circle/Settings/ManageInvitation/Invites/Pending/index.tsx @@ -15,25 +15,25 @@ import { useRoute, ViewerContext, } from '~/components' -import CIRCLE_INVITATIONS from '~/components/GQL/queries/circleInvitations' +import CIRCLE_PENDING_INVITES from '~/components/GQL/queries/circlePendingInvites' -import { REFETCH_CIRCLE_INVITATIONS } from '~/common/enums' +import { REFETCH_CIRCLE_PENDING_INVITES } from '~/common/enums' import { mergeConnections } from '~/common/utils' import styles from './styles.css' -import { CircleInvitations as CircleInvitationsType } from '~/components/GQL/queries/__generated__/CircleInvitations' +import { CirclePendingInvites } from '~/components/GQL/queries/__generated__/CirclePendingInvites' /** - * This component is for listing circle invitees. + * This component is for listing circle pending invitations. * * Usage: * * ``` - * + * * ``` */ -const Invitations = () => { +const PendingInvites = () => { const viewer = useContext(ViewerContext) const { getQuery } = useRoute() const name = getQuery('name') @@ -47,14 +47,14 @@ const Invitations = () => { error, fetchMore, refetch, - } = useQuery(CIRCLE_INVITATIONS, { + } = useQuery(CIRCLE_PENDING_INVITES, { variables: { name }, }) // pagination - const connectionPath = 'circle.invitations' + const connectionPath = 'circle.invites.pending' const circle = data?.circle - const { edges, pageInfo } = circle?.invitations || {} + const { edges, pageInfo } = circle?.invites.pending || {} const isOwner = circle?.owner.id === viewer.id // load next page @@ -70,7 +70,7 @@ const Invitations = () => { }) } - useEventListener(REFETCH_CIRCLE_INVITATIONS, refetch) + useEventListener(REFETCH_CIRCLE_PENDING_INVITES, refetch) usePullToRefresh.Handler(refetch) /** @@ -119,4 +119,4 @@ const Invitations = () => { ) } -export default Invitations +export default PendingInvites diff --git a/src/views/Circle/Settings/ManageInvitation/Invites/Pending/styles.css b/src/views/Circle/Settings/ManageInvitation/Invites/Pending/styles.css new file mode 100644 index 0000000000..3c6e0981c8 --- /dev/null +++ b/src/views/Circle/Settings/ManageInvitation/Invites/Pending/styles.css @@ -0,0 +1,9 @@ +.container { + padding-top: var(--spacing-base); + padding-bottom: var(--spacing-xxx-loose); + background: var(--color-white); + + @media (--sm-up) { + padding-bottom: var(--spacing-xx-loose); + } +} diff --git a/src/views/Circle/Settings/ManageInvitation/Invites/index.tsx b/src/views/Circle/Settings/ManageInvitation/Invites/index.tsx new file mode 100644 index 0000000000..e0fe715ab4 --- /dev/null +++ b/src/views/Circle/Settings/ManageInvitation/Invites/index.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' + +import { Spacer, Tabs, Translate } from '~/components' + +import AcceptedInvites from './Accepted' +import PendingInvites from './Pending' + +type InvitesType = 'accepted' | 'pending' + +const InvitesFeed: React.FC = () => { + const [type, setType] = useState('pending') + + const isPending = type === 'pending' + const isAccepted = type === 'accepted' + + return ( + <> + + + + setType('pending')} selected={isPending}> + + + + setType('accepted')} selected={isAccepted}> + + + + + {isPending && } + {isAccepted && } + + ) +} + +export default InvitesFeed diff --git a/src/views/Circle/Settings/ManageInvitation/index.tsx b/src/views/Circle/Settings/ManageInvitation/index.tsx index bd6b4fbe7f..60c89e3833 100644 --- a/src/views/Circle/Settings/ManageInvitation/index.tsx +++ b/src/views/Circle/Settings/ManageInvitation/index.tsx @@ -1,10 +1,10 @@ import { Head, Layout } from '~/components' import CircleInvitationAddButton from './AddButton' -import Invitations from './Invitations' +import InvitesFeed from './Invites' const ManageInvitation = () => ( - + } right={ @@ -17,7 +17,7 @@ const ManageInvitation = () => ( - + ) diff --git a/src/views/Circle/SubscriptionBanner/index.tsx b/src/views/Circle/SubscriptionBanner/index.tsx index 13414c4cb8..b65d491248 100644 --- a/src/views/Circle/SubscriptionBanner/index.tsx +++ b/src/views/Circle/SubscriptionBanner/index.tsx @@ -15,6 +15,7 @@ import { analytics } from '~/common/utils' import { fragments } from './gql' import styles from './styles.css' +import { InvitationState } from '@/__generated__/globalTypes' import { SubscriptionBannerCirclePrivate } from './__generated__/SubscriptionBannerCirclePrivate' import { SubscriptionBannerCirclePublic } from './__generated__/SubscriptionBannerCirclePublic' @@ -27,7 +28,7 @@ const SubscriptionBanner = ({ circle }: SubscriptionBannerProps) => { const viewer = useContext(ViewerContext) const isMember = circle.isMember const isOwner = circle?.owner?.id === viewer.id - const isInvited = circle?.invitedBy?.accepted === false + const isInvited = circle?.invitedBy?.state === InvitationState.pending if (isMember || isOwner) { return null diff --git a/src/views/Me/DraftDetail/PublishButton/PublishDialog/PublishContent.tsx b/src/views/Me/DraftDetail/PublishButton/PublishDialog/PublishContent.tsx index 7b2c235fc0..9ac9858ce1 100644 --- a/src/views/Me/DraftDetail/PublishButton/PublishDialog/PublishContent.tsx +++ b/src/views/Me/DraftDetail/PublishButton/PublishDialog/PublishContent.tsx @@ -63,9 +63,9 @@ const PublishContent: React.FC = ({ closeDialog }) => {

    diff --git a/src/views/Me/DraftDetail/index.tsx b/src/views/Me/DraftDetail/index.tsx index 1a4c30bc2d..c4331317c8 100644 --- a/src/views/Me/DraftDetail/index.tsx +++ b/src/views/Me/DraftDetail/index.tsx @@ -109,6 +109,9 @@ const DraftDetail = () => { content?: string | null cover?: string | null summary?: string | null + + initText?: string | null + currText?: string | null }) => { try { if (draft?.publishState === 'published') { @@ -116,6 +119,15 @@ const DraftDetail = () => { } setSaveStatus('saving') + + // remove unwanted props passing from editor module + if (newDraft.initText) { + delete newDraft.initText + } + if (newDraft.currText) { + delete newDraft.currText + } + await setContent({ variables: { id: draft?.id, ...newDraft } }) setSaveStatus('saved')