Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ import { CommunityManageSheet } from './communityManageSheet';
import { CommunityRoleEditSheet } from './communityRoleEditSheet';
import { SearchFiltersSheet } from './searchFiltersSheet';
import { NewsletterDigestSheet } from './newsletterDigestSheet';
import { NewsletterPostPrompt } from './newsletterPostPrompt';
import { NewsletterSenderInfo } from './newsletterSenderInfo';
import TransferFavoritesSheet from './transferFavoritesSheet/transferFavoritesSheet';

// Basic UI Elements
Expand Down Expand Up @@ -316,5 +318,7 @@ export {
CommunityRoleEditSheet,
SearchFiltersSheet,
NewsletterDigestSheet,
NewsletterPostPrompt,
NewsletterSenderInfo,
TransferFavoritesSheet,
};
1 change: 1 addition & 0 deletions src/components/newsletterPostPrompt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as NewsletterPostPrompt } from './newsletterPostPrompt';
151 changes: 151 additions & 0 deletions src/components/newsletterPostPrompt/newsletterPostPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { useIntl } from 'react-intl';
import { SheetManager } from 'react-native-actions-sheet';
import EStyleSheet from 'react-native-extended-stylesheet';
import { SheetNames } from '../../navigation/sheets';
import { useAuth } from '../../hooks';
import { useDigestSubscription } from '../../providers/queries';
import { getItemFromStorage, setItemToStorage } from '../../storage/storage';
import { IconButton } from '../iconButton';
import { pickPostDigestTarget, postPromptStorageKey } from './postDigestTarget';

interface Props {
post:
| { author?: string; category?: string; parent_author?: string; depth?: number }
| null
| undefined;
}

/**
* End-of-post subscribe card (web parity, vision-mobile#3520): offers the
* digest that would carry this post. Never shown while a subscription for
* that list exists; an explicit dismissal is remembered per viewer AND list.
*/
const NewsletterPostPrompt = ({ post }: Props) => {
const intl = useIntl();
const { username } = useAuth();

const target = useMemo(() => pickPostDigestTarget(post, username), [post, username]);

// null = storage answer pending; the card must not flash in before it.
const [dismissed, setDismissed] = useState<boolean | null>(null);

const storageKey =
target && username ? postPromptStorageKey(username, target.type, target.target) : null;

useEffect(() => {
let live = true;
setDismissed(null);
if (!storageKey) {
return undefined;
}
getItemFromStorage(storageKey)
.then((flag) => {
if (live) {
setDismissed(!!flag);
}
})
// A failing store also cannot have PERSISTED a dismissal, so offering
// is the consistent outcome; leaving null would hide the card forever.
.catch(() => {
if (live) {
setDismissed(false);
}
});
return () => {
live = false;
};
}, [storageKey]);

const subscriptionQuery = useDigestSubscription(target?.type ?? 'creator', target?.target ?? '');

// Render only once BOTH answers are in: the storage flag AND a successful
// subscriptions lookup. Unresolved data is "don't know", not "not
// subscribed" — rendering early would flash the card at existing
// subscribers, and on a failed lookup it would offer a sheet that can only
// report the service as unavailable.
if (
!target ||
dismissed !== false ||
!subscriptionQuery.isSuccess ||
subscriptionQuery.subscription
) {
return null;
}

const listLabel = target.type === 'creator' ? `@${target.target}` : target.target;

const _handleDismiss = () => {
setDismissed(true);
if (storageKey) {
// The in-session state above already hides the card; a lost write only
// costs persistence, never an unhandled rejection.
setItemToStorage(storageKey, { dismissedAt: new Date().toISOString() }).catch(() => {});
}
};

const _handleSubscribe = () => {
SheetManager.show(SheetNames.NEWSLETTER_DIGEST, {
payload: { type: target.type, target: target.target },
});
};

return (
<View style={styles.card}>
<View style={styles.textWrapper}>
<Text style={styles.text}>
{intl.formatMessage({ id: `newsletter.body_${target.type}` }, { list: listLabel })}
</Text>
</View>
<TouchableOpacity style={styles.subscribeButton} onPress={_handleSubscribe}>
<Text style={styles.subscribeText}>
{intl.formatMessage({ id: 'newsletter.subscribe' })}
</Text>
</TouchableOpacity>
<IconButton
iconType="MaterialIcons"
name="close"
size={18}
color={EStyleSheet.value('$primaryDarkGray')}
onPress={_handleDismiss}
/>
</View>
);
};

const styles = EStyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '$primaryLightBackground',
borderRadius: 12,
paddingVertical: 10,
paddingLeft: 12,
paddingRight: 4,
marginTop: 12,
marginHorizontal: 0,
},
textWrapper: {
flex: 1,
marginRight: 8,
},
text: {
fontSize: 13,
color: '$primaryDarkGray',
lineHeight: 18,
},
subscribeButton: {
backgroundColor: '$primaryBlue',
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 7,
},
subscribeText: {
fontSize: 13,
color: '$white',
fontWeight: '600',
},
});

export default NewsletterPostPrompt;
47 changes: 47 additions & 0 deletions src/components/newsletterPostPrompt/postDigestTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { pickPostDigestTarget, postPromptStorageKey } from './postDigestTarget';

const rootPost = { author: 'alice', category: 'photography', parent_author: '', depth: 0 };
const communityPost = { author: 'alice', category: 'hive-125125', parent_author: '', depth: 0 };

describe('pickPostDigestTarget', () => {
it('offers a reader the author creator digest on a root post', () => {
expect(pickPostDigestTarget(rootPost, 'bob')).toEqual({ type: 'creator', target: 'alice' });
expect(pickPostDigestTarget(communityPost, 'bob')).toEqual({
type: 'creator',
target: 'alice',
});
});

it('offers the author of a community post that community digest, and nothing on their own blog post', () => {
expect(pickPostDigestTarget(communityPost, 'alice')).toEqual({
type: 'community',
target: 'hive-125125',
});
expect(pickPostDigestTarget(rootPost, 'alice')).toBeNull();
});

it('treats a non-canonical category as a plain tag, not a community', () => {
const oddPost = { ...communityPost, category: 'other-hive-125125' };
// The author of such a post gets no community offer; a reader still gets
// the creator digest.
expect(pickPostDigestTarget(oddPost, 'alice')).toBeNull();
expect(pickPostDigestTarget(oddPost, 'bob')).toEqual({ type: 'creator', target: 'alice' });
});

it('offers nothing on comments or to anonymous viewers', () => {
expect(pickPostDigestTarget({ ...rootPost, parent_author: 'x', depth: 1 }, 'bob')).toBeNull();
expect(pickPostDigestTarget({ ...rootPost, depth: 2, parent_author: 'x' }, 'bob')).toBeNull();
expect(pickPostDigestTarget(rootPost, null)).toBeNull();
expect(pickPostDigestTarget(rootPost, undefined)).toBeNull();
expect(pickPostDigestTarget(null, 'bob')).toBeNull();
});

it('scopes the dismissal key per viewer and list', () => {
expect(postPromptStorageKey('bob', 'creator', 'alice')).toBe(
'digest_post_prompt_bob_creator_alice',
);
expect(postPromptStorageKey('bob', 'creator', 'alice')).not.toBe(
postPromptStorageKey('carol', 'creator', 'alice'),
);
});
});
43 changes: 43 additions & 0 deletions src/components/newsletterPostPrompt/postDigestTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { DigestType } from '@ecency/sdk';

// Anchored ON PURPOSE: the shared isCommunity() matches only the suffix, so a
// category like `other-hive-125125` would pass and target a digest list that
// does not exist. A digest target must be the canonical community id.
const COMMUNITY_RE = /^hive-[1-3]\d{4,6}$/;

export interface PostDigestTarget {
type: DigestType;
target: string;
}

/**
* Which digest an end-of-post card offers, mirroring the website: a reader is
* offered the AUTHOR's creator digest; the author reading their own COMMUNITY
* post is offered the community's digest (the list that would carry this
* post); the author's own non-community post offers nothing. Comments and
* anonymous viewers offer nothing (mobile subscribes are signed-in only).
*/
export const pickPostDigestTarget = (
post:
| { author?: string; category?: string; parent_author?: string; depth?: number }
| null
| undefined,
viewer: string | null | undefined,
): PostDigestTarget | null => {
if (!post?.author || !viewer) {
return null;
}
const isRoot = !post.parent_author && !(typeof post.depth === 'number' && post.depth > 0);
if (!isRoot) {
return null;
}
const community = post.category && COMMUNITY_RE.test(post.category) ? post.category : null;
if (viewer === post.author) {
return community ? { type: 'community', target: community } : null;
}
return { type: 'creator', target: post.author };
};

/** Per viewer AND list, so dismissing one author's card never hides another's. */
export const postPromptStorageKey = (viewer: string, type: string, target: string) =>
`digest_post_prompt_${viewer}_${type}_${target}`;
1 change: 1 addition & 0 deletions src/components/newsletterSenderInfo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as NewsletterSenderInfo } from './newsletterSenderInfo';
86 changes: 86 additions & 0 deletions src/components/newsletterSenderInfo/newsletterSenderInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { useIntl } from 'react-intl';
import { useNavigation } from '@react-navigation/native';
import { useQuery } from '@tanstack/react-query';
import Clipboard from '@react-native-clipboard/clipboard';
import EStyleSheet from 'react-native-extended-stylesheet';
import { getNewsletterSenderQueryOptions } from '@ecency/sdk';
import ROUTES from '../../constants/routeNames';
import { useAppDispatch, useAuth } from '../../hooks';
import { toastNotification } from '../../redux/actions/uiAction';
import { IconButton } from '../iconButton';

interface Props {
username: string;
}

/**
* The creator's own list at a glance on their profile (web parity,
* vision-mobile#3520): weekly/monthly mailable subscriber counts, the
* copyable subscribe link, and the way into digest management. The sender
* view is gated to the list owner server-side, so this mounts only on the
* own profile and stays silent while the lookup is unresolved or refused.
*/
const NewsletterSenderInfo = ({ username }: Props) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const navigation = useNavigation();
const { username: authUsername, code } = useAuth();

const senderQuery = useQuery(
getNewsletterSenderQueryOptions('creator', username, authUsername, code),
);

const subscribers = senderQuery.data?.subscribers;
if (!subscribers) {
return null;
}

const _handleCopyLink = () => {
Clipboard.setString(`https://ecency.com/@${username}?subscribe=digest`);
dispatch(toastNotification(intl.formatMessage({ id: 'alert.copied' })));
};

return (
<View style={styles.row}>
<Text style={styles.countText}>
{intl.formatMessage(
{ id: 'newsletter.subscriber_count' },
{ weekly: subscribers.weekly ?? 0, monthly: subscribers.monthly ?? 0 },
)}
</Text>
<IconButton
iconType="MaterialCommunityIcons"
name="link-variant"
size={18}
color={EStyleSheet.value('$primaryDarkGray')}
onPress={_handleCopyLink}
/>
<TouchableOpacity onPress={() => navigation.navigate(ROUTES.SCREENS.EMAIL_DIGESTS)}>
<Text style={styles.manageText}>{intl.formatMessage({ id: 'newsletter.manage' })}</Text>
</TouchableOpacity>
</View>
);
};

const styles = EStyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
paddingTop: 6,
},
countText: {
fontSize: 13,
color: '$primaryDarkGray',
},
manageText: {
fontSize: 13,
color: '$primaryBlue',
marginLeft: 8,
},
});

export default NewsletterSenderInfo;
2 changes: 2 additions & 0 deletions src/components/postView/view/postDisplayView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { PostTypes } from '../../../constants/postTypes';
import { useCheckIn } from '../../../providers/queries/pointQueries';
import { PostComments } from '../../postComments';
import { SimilarEntries } from '../../similarEntries';
import { NewsletterPostPrompt } from '../../newsletterPostPrompt';
import { UpvoteButton } from '../../postCard/children/upvoteButton';
import UpvotePopover from '../../upvotePopover';
import { PostPoll } from '../../postPoll';
Expand Down Expand Up @@ -527,6 +528,7 @@ const PostDisplayView = ({
/>
</View>
)}
{!postBodyLoading && <NewsletterPostPrompt post={post} />}
{!postBodyLoading && <SimilarEntries post={post} />}
</View>
)}
Expand Down
3 changes: 3 additions & 0 deletions src/components/profileSummary/view/profileSummaryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { makeCountFriendly } from '../../../utils/formatter';
import styles from './profileSummaryStyles';
import getWindowDimensions from '../../../utils/getWindowDimensions';
import { SheetNames } from '../../../navigation/sheets';
import { NewsletterSenderInfo } from '../../newsletterSenderInfo';

const DEVICE_WIDTH = getWindowDimensions().width;

Expand Down Expand Up @@ -314,13 +315,15 @@ class ProfileSummaryView extends PureComponent<any, any> {
};

render() {
const { isOwnProfile, username } = this.props;
return (
<Fragment>
{this._renderCoverImage()}
{this._renderAvatarAndActions()}
{this._renderIdentity()}
{this._renderMetadata()}
{this._renderFollowerStats()}
{!!isOwnProfile && !!username && <NewsletterSenderInfo username={username} />}
{this._renderBars()}
</Fragment>
);
Expand Down
Loading
Loading