Skip to content

Commit

Permalink
Social: Add Bluesky connection UI (#94986)
Browse files Browse the repository at this point in the history
* Extract common types from Mastodon

* Add Bluesky form inputs

* Enable Bluesky UIs

* Clean up and make up

* Fix unit test
  • Loading branch information
manzoorwanijk authored Sep 30, 2024
1 parent a8aee56 commit 6e05a7d
Show file tree
Hide file tree
Showing 15 changed files with 266 additions and 54 deletions.
1 change: 1 addition & 0 deletions client/components/vertical-menu/items/social-item.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import SocialLogo from 'calypso/components/social-logo';
import './style.scss';

const services = ( translate = ( string ) => string ) => ( {
bluesky: { icon: 'bluesky', label: translate( 'Bluesky' ) },
facebook: { icon: 'facebook', label: translate( 'Facebook' ) },
'instagram-business': { icon: 'instagram', label: translate( 'Instagram' ) },
google: { icon: 'google', label: translate( 'Google search' ) },
Expand Down
159 changes: 159 additions & 0 deletions client/my-sites/marketing/connections/bluesky.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { ExternalLink, FormInputValidation, FormLabel, Spinner } from '@automattic/components';
import { useTranslate } from 'i18n-calypso';
import { FormEvent, useEffect, useId, useRef, useState } from 'react';
import FormsButton from 'calypso/components/forms/form-button';
import FormSettingExplanation from 'calypso/components/forms/form-setting-explanation';
import { Connection, Service } from './types';

interface Props {
service: Service;
action: () => void;
connectAnother: () => void;
connections: Connection[];
isConnecting: boolean;
}

/**
* Example valid handles:
* - username.bsky.social
* - user-name.bsky.social
* - my_domain.com.bsky.social
* - my-domain.com.my-own-server.com
* @param {string} handle - Handle to validate
* @returns {boolean} - Whether the handle is valid
*/
function isValidBlueskyHandle( handle: string ) {
const parts = handle.split( '.' ).filter( Boolean );

// A valid handle should have at least 3 parts - username, domain, and tld
if ( parts.length < 3 ) {
return false;
}

return parts.every( ( part ) => /^[a-z0-9_-]+$/i.test( part ) );
}

const isAlreadyConnected = ( connections: Array< Connection >, handle: string ) => {
return connections.some( ( connection ) => {
const { external_display } = connection;
return external_display === handle;
} );
};

export const Bluesky: React.FC< Props > = ( {
service,
action,
connectAnother,
connections,
isConnecting,
} ) => {
const translate = useTranslate();
const [ error, setError ] = useState( '' );
const formRef = useRef< HTMLFormElement >( null );

// After sucessfully connecting an account, reset the handle.
// Disabled react-hooks/exhaustive-deps because we don't want to run this on handle change
useEffect( () => {
const handle = formRef.current?.elements.namedItem( 'handle' ) as HTMLInputElement;

if ( ! isConnecting && isAlreadyConnected( connections, handle.value ) ) {
formRef.current?.reset();
}
}, [ isConnecting, connections ] ); // eslint-disable-line react-hooks/exhaustive-deps

/**
* Handle the Connect account submission.
*/
const handleSubmit = ( e: FormEvent< HTMLFormElement > ) => {
e.preventDefault();
e.stopPropagation();

const formData = new FormData( e.target as HTMLFormElement );

// Let us make the user's life easier by removing the leading "@" if they added it
const handle = ( formData.get( 'handle' )?.toString().trim() || '' ).replace( /^@/, '' );
const app_password = formData.get( 'app_password' )?.toString().trim() || '';

if ( isAlreadyConnected( connections, handle ) ) {
return setError( translate( 'This account is already connected.' ) );
}

if ( ! handle || ! isValidBlueskyHandle( handle ) ) {
return setError( translate( 'Please enter a valid handle.' ) );
}

const url = new URL( service.connect_URL );
url.searchParams.set( 'handle', handle );
url.searchParams.set( 'app_password', app_password );

// TODO: Fix this to avoid mutating props
service.connect_URL = url.toString();

connections.length >= 1 ? connectAnother() : action();
};

const id = useId();

const showError = !! error;
return (
<div className="sharing-service-distributed-example">
<form onSubmit={ handleSubmit } ref={ formRef }>
<div>
<FormLabel htmlFor={ `${ id }-handle` }>
{ translate( 'Handle', { comment: 'Bluesky account handle' } ) }
</FormLabel>
<FormSettingExplanation>
{ translate( 'You can find the handle in your Bluesky profile.' ) }
</FormSettingExplanation>
<input
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
id={ `${ id }-handle` }
name="handle"
placeholder="username.bsky.social"
required
type="text"
className="form-text-input"
/>
{ showError && <FormInputValidation isError text={ error } /> }
</div>
<div>
<FormLabel htmlFor={ `${ id }-app-password` }>{ translate( 'App password' ) }</FormLabel>
<FormSettingExplanation>
{ translate(
'App password is needed to safely connect your account. App password is different from your account password. You can {{link}}generate it in Bluesky{{/link}}.',
{
components: {
link: <ExternalLink href="https://bsky.app/settings/app-passwords" />,
},
}
) }
</FormSettingExplanation>
<input
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
id={ `${ id }-app-password` }
name="app_password"
type="password"
placeholder="xxxx-xxxx-xxxx-xxxx"
required
className="form-text-input"
/>
{ showError && <FormInputValidation isError text={ error } /> }
</div>
<div>
<FormsButton primary type="submit" disabled={ isConnecting }>
{ translate( 'Connect account' ) }
{ isConnecting && <Spinner /> }
</FormsButton>
</div>
</form>
</div>
);
};

export default Bluesky;
1 change: 1 addition & 0 deletions client/my-sites/marketing/connections/connection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class SharingConnection extends Component {
mastodon: 'user',
threads: 'user',
nextdoor: 'user',
bluesky: 'user',
},
};

Expand Down
51 changes: 6 additions & 45 deletions client/my-sites/marketing/connections/mastodon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslate } from 'i18n-calypso';
import { useState, FormEvent, ChangeEvent, useEffect } from 'react';
import FormsButton from 'calypso/components/forms/form-button';
import FormTextInput from 'calypso/components/forms/form-text-input';
import { Connection, Service } from './types';

interface Props {
service: Service;
Expand All @@ -13,45 +14,6 @@ interface Props {
isConnecting: boolean;
}

interface Service {
ID: string;
connect_URL: string;
description: string;
external_users_only: boolean;
genericon: {
class: string;
unicode: string;
};
icon: string;
jetpack_module_required: string;
jetpack_support: boolean;
label: string;
multiple_external_user_ID_support: boolean;
type: string;
}

interface Connection {
ID: number;
site_ID: number;
user_ID: number;
keyring_connection_ID: number;
keyring_connection_user_ID: number;
shared: boolean;
service: string;
label: string;
issued: string;
expires: string;
external_ID: string | null;
external_name: string | null;
external_display: string | null;
external_profile_picture: string | null;
external_profile_URL: string | null;
external_follower_count: number | null;
status: string;
refresh_URL: string;
meta: object;
}

const InstanceContainer = styled.div( {
alignItems: 'center',
display: 'flex',
Expand Down Expand Up @@ -109,6 +71,7 @@ export const Mastodon: React.FC< Props > = ( {
const setInstanceToConnectURL = () => {
const url = new URL( service.connect_URL );
url.searchParams.set( 'instance', instance );
// TODO: Fix this to avoid mutating props
service.connect_URL = url.toString();
};

Expand All @@ -125,7 +88,7 @@ export const Mastodon: React.FC< Props > = ( {
return (
<div className="sharing-service-distributed-example">
<form onSubmit={ handleSubmit }>
<div className="sharing-service-example">
<div>
<FormLabel htmlFor="instance">{ translate( 'Enter your Mastodon username' ) }</FormLabel>
<InstanceContainer>
<FormTextInput
Expand All @@ -139,19 +102,17 @@ export const Mastodon: React.FC< Props > = ( {
onChange={ handleInstanceChange }
placeholder="@mastodon@mastodon.social"
/>
{ isConnecting && <Spinner /> }
</InstanceContainer>
{ showError && <FormInputValidation isError text={ error } /> }
</div>
<div className="sharing-service-example">
<div>
<FormsButton
primary
type="submit"
disabled={ ! isValidUsername( instance ) || showError || isConnecting }
>
{ connections.length >= 1
? translate( 'Connect one more account' )
: translate( 'Connect account' ) }
{ translate( 'Connect account' ) }
{ isConnecting && <Spinner /> }
</FormsButton>
</div>
</form>
Expand Down
2 changes: 1 addition & 1 deletion client/my-sites/marketing/connections/service-action.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const SharingServiceAction = ( {
);
}

if ( 'mastodon' === service.ID ) {
if ( 'mastodon' === service.ID || 'bluesky' === service.ID ) {
return (
<Button
scary={ warning }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PropTypes from 'prop-types';

const SharingServiceConnectedAccounts = ( { children, connect, service, translate } ) => {
const allowMultipleAccounts = [ 'instagram-basic-display', 'p2_github' ];
const doesNotAllowMultipleAccounts = [ 'google_plus', 'mastodon' ];
const doesNotAllowMultipleAccounts = [ 'google_plus', 'mastodon', 'bluesky' ];
const shouldShowConnectButton =
( 'publicize' === service.type || allowMultipleAccounts.includes( service.ID ) ) &&
! doesNotAllowMultipleAccounts.includes( service.ID );
Expand Down
6 changes: 6 additions & 0 deletions client/my-sites/marketing/connections/service-description.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class SharingServiceDescription extends Component {

static defaultProps = {
descriptions: Object.freeze( {
bluesky() {
if ( this.props.numberOfConnections > 0 ) {
return this.props.translate( 'Sharing posts to your Bluesky profile.' );
}
return this.props.translate( 'Share posts to your Bluesky profile.' );
},
facebook: function () {
if ( this.props.numberOfConnections > 0 ) {
return this.props.translate(
Expand Down
14 changes: 14 additions & 0 deletions client/my-sites/marketing/connections/service-examples.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import googleDriveExample from 'calypso/assets/images/connections/google-drive-s
import isJetpackCloud from 'calypso/lib/jetpack/is-jetpack-cloud';
import { isJetpackSite } from 'calypso/state/sites/selectors';
import { getSelectedSite, getSelectedSiteId } from 'calypso/state/ui/selectors';
import Bluesky from './bluesky';
import GooglePlusDeprication from './google-plus-deprecation';
import Mastodon from './mastodon';
import ServiceExample from './service-example';
Expand Down Expand Up @@ -44,6 +45,7 @@ const SERVICES_WITH_EXAMPLES = [
'p2_slack',
'p2_github',
'mastodon',
'bluesky',
];

class SharingServiceExamples extends Component {
Expand Down Expand Up @@ -558,6 +560,18 @@ class SharingServiceExamples extends Component {
);
}

if ( 'bluesky' === this.props.service.ID ) {
return (
<Bluesky
service={ this.props.service }
action={ this.props.action }
connectAnother={ this.props.connectAnother }
connections={ this.props.connections }
isConnecting={ this.props.isConnecting }
/>
);
}

const examples = this[ this.props.service.ID.replace( /-/g, '_' ) ]();

return (
Expand Down
30 changes: 30 additions & 0 deletions client/my-sites/marketing/connections/service-examples.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,33 @@
margin-top: 16px;
}
}

.sharing-service-distributed-example {
form {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 2rem;
max-width: 25rem;

// Override the weird styling for examples
.sharing-service-example {
@include breakpoint-deprecated( "<660px" ) {
margin: 0;
}
&:first-child {

@include breakpoint-deprecated( "<480px" ) {
margin-bottom: 0;
}
}
}
}
.form-setting-explanation {
margin-bottom: 1rem;
}
.form-button {
display: flex;
gap: 0.5rem;
}
}
2 changes: 1 addition & 1 deletion client/my-sites/marketing/connections/service.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ export class SharingService extends Component {
compact
summary={ action }
expandedSummary={
this.props.service.ID === 'mastodon'
this.props.service.ID === 'mastodon' || this.props.service.ID === 'bluesky'
? cloneElement( action, { isExpanded: true } )
: action
}
Expand Down
5 changes: 5 additions & 0 deletions client/my-sites/marketing/connections/services/bluesky.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SharingService, connectFor } from 'calypso/my-sites/marketing/connections/service';

export class Bluesky extends SharingService {}

export default connectFor( Bluesky, ( state, props ) => props );
2 changes: 2 additions & 0 deletions client/my-sites/marketing/connections/services/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export { default as p2_slack } from './p2-slack';
export { default as p2_github } from './p2-github';
export { default as nextdoor } from './nextdoor';
export { default as threads } from './threads';
export { default as bluesky } from './bluesky';

const services = new Set( [
'bluesky',
'p2_github',
'p2_slack',
'fediverse',
Expand Down
Loading

0 comments on commit 6e05a7d

Please sign in to comment.