Announcements framework (#1494)

This commit is contained in:
infinite-persistence 2022-05-20 00:36:29 +08:00
commit 581ae13c3f
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
24 changed files with 299 additions and 27 deletions

View file

@ -15,6 +15,7 @@ declare type ContentState = {
// It can/should be '?Array<string>` instead -- set it to null, then clients
// can cast it to a boolean. That, or rename the variable to `shuffle` if you
// don't care about the URLs.
lastViewedAnnouncement: ?string, // undefined = not seen in wallet.
};
declare type WatchHistory = {

View file

@ -2259,6 +2259,8 @@
"Still Valid Until": "Still Valid Until",
"Active channel": "Active channel",
"Select your default active channel": "Select your default active channel",
"What's New": "What's New",
"See what are the latest features and changes in Odysee.": "See what are the latest features and changes in Odysee.",
"This account has livestreaming disabled, please reach out to hello@odysee.com for assistance.": "This account has livestreaming disabled, please reach out to hello@odysee.com for assistance.",
"Attach images by pasting or drag-and-drop.": "Attach images by pasting or drag-and-drop.",
"There was a network error, but the publish may have been completed. Wait a few minutes, then check your Uploads or Wallet page to confirm.": "There was a network error, but the publish may have been completed. Wait a few minutes, then check your Uploads or Wallet page to confirm.",

View file

@ -1,7 +1,13 @@
import { hot } from 'react-hot-loader/root';
import { connect } from 'react-redux';
import { selectGetSyncErrorMessage, selectSyncFatalError, selectSyncIsLocked } from 'redux/selectors/sync';
import {
selectGetSyncErrorMessage,
selectPrefsReady,
selectSyncFatalError,
selectSyncIsLocked,
} from 'redux/selectors/sync';
import { doUserSetReferrer } from 'redux/actions/user';
import { doSetLastViewedAnnouncement } from 'redux/actions/content';
import {
selectOdyseeMembershipIsPremiumPlus,
selectUser,
@ -15,7 +21,7 @@ import { selectMyChannelClaimIds } from 'redux/selectors/claims';
import { selectLanguage, selectLoadedLanguages, selectThemePath } from 'redux/selectors/settings';
import { selectModal, selectActiveChannelClaim, selectIsReloadRequired } from 'redux/selectors/app';
import { selectUploadCount } from 'redux/selectors/publish';
import { doSetLanguage } from 'redux/actions/settings';
import { doOpenAnnouncements, doSetLanguage } from 'redux/actions/settings';
import { doSyncLoop } from 'redux/actions/sync';
import { doSignIn, doSetIncognito } from 'redux/actions/app';
import { doFetchModBlockedList, doFetchCommentModAmIList } from 'redux/actions/comments';
@ -28,6 +34,7 @@ const select = (state) => ({
language: selectLanguage(state),
languages: selectLoadedLanguages(state),
isReloadRequired: selectIsReloadRequired(state),
prefsReady: selectPrefsReady(state),
syncError: selectGetSyncErrorMessage(state),
syncIsLocked: selectSyncIsLocked(state),
uploadCount: selectUploadCount(state),
@ -51,6 +58,8 @@ const perform = {
setIncognito: doSetIncognito,
fetchModBlockedList: doFetchModBlockedList,
fetchModAmIList: doFetchCommentModAmIList,
doOpenAnnouncements,
doSetLastViewedAnnouncement,
};
export default hot(connect(select, perform)(App));

View file

@ -74,6 +74,7 @@ type Props = {
balance: ?number,
syncIsLocked: boolean,
syncError: ?string,
prefsReady: boolean,
rewards: Array<Reward>,
setReferrer: (string, boolean) => void,
isAuthenticated: boolean,
@ -87,6 +88,8 @@ type Props = {
fetchModBlockedList: () => void,
fetchModAmIList: () => void,
homepageFetched: boolean,
doOpenAnnouncements: () => void,
doSetLastViewedAnnouncement: (hash: string) => void,
};
function App(props: Props) {
@ -103,6 +106,7 @@ function App(props: Props) {
history,
syncError,
syncIsLocked,
prefsReady,
language,
languages,
setLanguage,
@ -119,6 +123,8 @@ function App(props: Props) {
hasPremiumPlus,
fetchModAmIList,
homepageFetched,
doOpenAnnouncements,
doSetLastViewedAnnouncement,
} = props;
const isMobile = useIsMobile();
@ -438,7 +444,7 @@ function App(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps, (one time after locale is fetched)
}, [locale]);
React.useEffect(() => {
useEffect(() => {
if (locale) {
const countryCode = locale.country;
const langs = getLanguagesForCountry(countryCode) || [];
@ -471,6 +477,19 @@ function App(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncError, pathname, isAuthenticated]);
useEffect(() => {
if (prefsReady) {
doOpenAnnouncements();
}
}, [prefsReady]);
useEffect(() => {
window.clearLastViewedAnnouncement = () => {
console.log('Clearing history. Please wait ...');
doSetLastViewedAnnouncement('');
};
}, []);
// Keep this at the end to ensure initial setup effects are run first
useEffect(() => {
if (!hasSignedIn && hasVerifiedEmail) {

View file

@ -143,6 +143,7 @@ export const CLEAR_CONTENT_POSITION = 'CLEAR_CONTENT_POSITION';
export const SET_CONTENT_LAST_VIEWED = 'SET_CONTENT_LAST_VIEWED';
export const CLEAR_CONTENT_HISTORY_URI = 'CLEAR_CONTENT_HISTORY_URI';
export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL';
export const SET_LAST_VIEWED_ANNOUNCEMENT = 'SET_LAST_VIEWED_ANNOUNCEMENT';
export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED';
export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED';
export const TOGGLE_LOOP_LIST = 'TOGGLE_LOOP_LIST';

View file

@ -51,3 +51,4 @@ export const CONFIRM_REMOVE_COMMENT = 'CONFIRM_REMOVE_COMMENT';
export const CONFIRM_ODYSEE_MEMBERSHIP = 'CONFIRM_ODYSEE_MEMBERSHIP';
export const MEMBERSHIP_SPLASH = 'MEMBERSHIP_SPLASH';
export const HIDE_RECOMMENDATION = 'HIDE_RECOMMENDATION';
export const ANNOUNCEMENTS = 'ANNOUNCEMENTS';

View file

@ -0,0 +1,20 @@
import ModalAnnouncements from './view';
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import { doSetLastViewedAnnouncement } from 'redux/actions/content';
import { selectLastViewedAnnouncement } from 'redux/selectors/content';
import { selectHomepageAnnouncement } from 'redux/selectors/settings';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
const select = (state) => ({
authenticated: selectUserVerifiedEmail(state),
announcement: selectHomepageAnnouncement(state),
lastViewedHash: selectLastViewedAnnouncement(state),
});
const perform = {
doHideModal,
doSetLastViewedAnnouncement,
};
export default connect(select, perform)(ModalAnnouncements);

View file

@ -0,0 +1,68 @@
// @flow
import React from 'react';
import { useHistory } from 'react-router-dom';
import Card from 'component/common/card';
import MarkdownPreview from 'component/common/markdown-preview';
import * as PAGES from 'constants/pages';
import { Modal } from 'modal/modal';
import { getSimpleStrHash } from 'util/string';
type Props = {
isAutoInvoked?: boolean,
// --- redux ---
authenticated?: boolean,
announcement: string,
lastViewedHash: ?string,
doHideModal: () => void,
doSetLastViewedAnnouncement: (hash: string) => void,
};
export default function ModalAnnouncements(props: Props) {
const {
authenticated,
announcement,
lastViewedHash,
isAutoInvoked,
doHideModal,
doSetLastViewedAnnouncement,
} = props;
const {
location: { pathname },
} = useHistory();
const [show, setShow] = React.useState(false);
React.useEffect(() => {
if (!authenticated || (pathname !== '/' && pathname !== `/$/${PAGES.HELP}`) || announcement === '') {
doHideModal();
return;
}
const hash = getSimpleStrHash(announcement);
if (lastViewedHash === hash) {
if (isAutoInvoked) {
doHideModal();
} else {
setShow(true);
}
} else {
setShow(true);
doSetLastViewedAnnouncement(hash);
}
}, []);
if (!show) {
return null;
}
return (
<Modal type="card" isOpen onAborted={doHideModal}>
<Card
className="announcement"
actions={<MarkdownPreview className="markdown-preview--announcement" content={announcement} simpleLinks />}
/>
</Modal>
);
}

View file

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { selectModal } from 'redux/selectors/app';
import { doOpenModal, doHideModal } from 'redux/actions/app';
import { doHideModal } from 'redux/actions/app';
import { selectError } from 'redux/selectors/notifications'; // RENAME THIS 'selectNotificationError'
import ModalRouter from './view';
@ -9,9 +9,8 @@ const select = (state, props) => ({
error: selectError(state),
});
const perform = (dispatch) => ({
openModal: (props) => dispatch(doOpenModal(props)),
hideModal: (props) => dispatch(doHideModal(props)),
});
const perform = {
hideModal: doHideModal,
};
export default connect(select, perform)(ModalRouter);

View file

@ -7,6 +7,7 @@ import { lazyImport } from 'util/lazyImport';
// prettier-ignore
const MAP = Object.freeze({
[MODALS.ANNOUNCEMENTS]: lazyImport(() => import('modal/modalAnnouncements' /* webpackChunkName: "modalAnnouncements" */)),
[MODALS.AFFIRM_PURCHASE]: lazyImport(() => import('modal/modalAffirmPurchase' /* webpackChunkName: "modalAffirmPurchase" */)),
[MODALS.AUTO_GENERATE_THUMBNAIL]: lazyImport(() => import('modal/modalAutoGenerateThumbnail' /* webpackChunkName: "modalAutoGenerateThumbnail" */)),
[MODALS.AUTO_UPDATE_DOWNLOADED]: lazyImport(() => import('modal/modalAutoUpdateDownloaded' /* webpackChunkName: "modalAutoUpdateDownloaded" */)),

View file

@ -1,3 +1,14 @@
import HelpPage from './view';
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import { selectHomepageAnnouncement } from 'redux/selectors/settings';
export default HelpPage;
const select = (state) => ({
announcement: selectHomepageAnnouncement(state),
});
const perform = {
doOpenModal,
};
export default connect(select, perform)(HelpPage);

View file

@ -7,9 +7,35 @@ import Page from 'component/page';
import Card from 'component/common/card';
import I18nMessage from 'component/i18nMessage';
export default function HelpPage() {
import * as MODALS from 'constants/modal_types';
type Props = {
announcement: string,
doOpenModal: (string, ?{}) => void,
};
export default function HelpPage(props: Props) {
const { announcement, doOpenModal } = props;
return (
<Page className="card-stack">
{announcement && (
<Card
title={__("What's New")}
subtitle={__('See what are the latest features and changes in Odysee.')}
actions={
<div className="section__actions">
<Button
label={__("What's New")}
icon={ICONS.FEEDBACK}
button="secondary"
onClick={() => doOpenModal(MODALS.ANNOUNCEMENTS)}
/>
</div>
}
/>
)}
<Card
title={__('Visit the %SITE_NAME% Help Hub', { SITE_NAME })}
subtitle={__('Our support posts answer many common questions.')}

View file

@ -374,3 +374,12 @@ export function doToggleShuffleList(currentUri: string, collectionId: string, sh
}
};
}
export function doSetLastViewedAnnouncement(hash: string) {
return (dispatch: Dispatch) => {
dispatch({
type: ACTIONS.SET_LAST_VIEWED_ANNOUNCEMENT,
data: hash,
});
};
}

View file

@ -3,6 +3,7 @@ import { doWalletReconnect } from 'redux/actions/wallet';
import * as SETTINGS from 'constants/settings';
import * as DAEMON_SETTINGS from 'constants/daemon_settings';
import * as ACTIONS from 'constants/action_types';
import * as MODALS from 'constants/modal_types';
import * as SHARED_PREFERENCES from 'constants/shared_preferences';
import { doToast } from 'redux/actions/notifications';
import analytics from 'analytics';
@ -10,7 +11,7 @@ import SUPPORTED_LANGUAGES from 'constants/supported_languages';
import { launcher } from 'util/autoLaunch';
import { selectClientSetting } from 'redux/selectors/settings';
import { doSyncLoop, doSyncUnsubscribe, doSetSyncLock } from 'redux/actions/sync';
import { doAlertWaitingForSync, doGetAndPopulatePreferences } from 'redux/actions/app';
import { doAlertWaitingForSync, doGetAndPopulatePreferences, doOpenModal } from 'redux/actions/app';
import { selectPrefsReady } from 'redux/selectors/sync';
import { Lbryio } from 'lbryinc';
import { getDefaultLanguage } from 'util/default-languages';
@ -317,23 +318,38 @@ function populateCategoryTitles(categories) {
}
}
function loadBuiltInHomepageData(dispatch) {
const homepages = require('homepages');
if (homepages) {
const v2 = {};
const homepageKeys = Object.keys(homepages);
homepageKeys.forEach((hp) => {
v2[hp] = homepages[hp];
});
window.homepages = v2;
populateCategoryTitles(window.homepages?.en?.categories);
dispatch({ type: ACTIONS.FETCH_HOMEPAGES_DONE });
}
}
export function doOpenAnnouncements() {
return (dispatch) => {
// There is a weird useEffect in modalRouter that closes all modals on
// initial mount. Not sure what scenario that covers, so just delay a bit
// until it is mounted.
setTimeout(() => {
dispatch(doOpenModal(MODALS.ANNOUNCEMENTS, { isAutoInvoked: true }));
}, 1000);
};
}
export function doFetchHomepages() {
return (dispatch) => {
// -- Use this env flag to use local homepage data. Otherwise, it will grab from `/$/api/content/v*/get`.
// -- Use this env flag to use local homepage data and meme (faster).
// -- Otherwise, it will grab from `/$/api/content/v*/get`.
// @if USE_LOCAL_HOMEPAGE_DATA='true'
const homepages = require('homepages');
if (homepages) {
const v2 = {};
const homepageKeys = Object.keys(homepages);
homepageKeys.forEach((hp) => {
v2[hp] = homepages[hp];
});
window.homepages = v2;
populateCategoryTitles(window.homepages?.en?.categories);
dispatch({ type: ACTIONS.FETCH_HOMEPAGES_DONE });
return;
}
loadBuiltInHomepageData(dispatch);
// @endif
fetch('https://odysee.com/$/api/content/v2/get')

View file

@ -421,6 +421,7 @@ type SharedData = {
editedCollections: CollectionGroup,
builtinCollections: CollectionGroup,
savedCollections: Array<string>,
lastViewedAnnouncement: string,
},
};
@ -439,6 +440,7 @@ function extractUserState(rawObj: SharedData) {
editedCollections,
builtinCollections,
savedCollections,
lastViewedAnnouncement,
} = rawObj.value;
return {
@ -454,6 +456,7 @@ function extractUserState(rawObj: SharedData) {
...(editedCollections ? { editedCollections } : {}),
...(builtinCollections ? { builtinCollections } : {}),
...(savedCollections ? { savedCollections } : {}),
...(lastViewedAnnouncement ? { lastViewedAnnouncement } : {}),
};
}
@ -475,6 +478,7 @@ export function doPopulateSharedUserState(sharedSettings: any) {
editedCollections,
builtinCollections,
savedCollections,
lastViewedAnnouncement,
} = extractUserState(sharedSettings);
dispatch({
type: ACTIONS.USER_STATE_POPULATE,
@ -491,6 +495,7 @@ export function doPopulateSharedUserState(sharedSettings: any) {
editedCollections,
builtinCollections,
savedCollections,
lastViewedAnnouncement,
},
});
};

View file

@ -13,6 +13,7 @@ const defaultState: ContentState = {
recommendationUrls: {},
recommendationClicks: {},
loopList: undefined,
lastViewedAnnouncement: '',
};
reducers[ACTIONS.SET_PRIMARY_URI] = (state, action) =>
@ -118,6 +119,8 @@ reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => {
reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [] });
reducers[ACTIONS.SET_LAST_VIEWED_ANNOUNCEMENT] = (state, action) => ({ ...state, lastViewedAnnouncement: action.data });
// reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => {
// return {
// ...state,
@ -125,6 +128,11 @@ reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [
// };
// };
reducers[ACTIONS.USER_STATE_POPULATE] = (state, action) => {
const { lastViewedAnnouncement } = action.data;
return { ...state, lastViewedAnnouncement };
};
export default function reducer(state: ContentState = defaultState, action: any) {
const handler = reducers[action.type];
if (handler) return handler(state, action);

View file

@ -26,6 +26,7 @@ export const selectPlayingUri = (state: State) => selectState(state).playingUri;
export const selectPrimaryUri = (state: State) => selectState(state).primaryUri;
export const selectListLoop = (state: State) => selectState(state).loopList;
export const selectListShuffle = (state: State) => selectState(state).shuffleList;
export const selectLastViewedAnnouncement = (state: State) => selectState(state).lastViewedAnnouncement;
export const makeSelectIsPlaying = (uri: string) =>
createSelector(selectPrimaryUri, (primaryUri) => primaryUri === uri);

View file

@ -82,6 +82,18 @@ export const selectHomepageMeme = (state) => {
return homepages ? homepages['en'].meme || {} : {};
};
export const selectHomepageAnnouncement = (state) => {
const homepageCode = selectHomepageCode(state);
const homepages = window.homepages;
if (homepages) {
const news = homepages[homepageCode].announcement;
if (news) {
return news;
}
}
return homepages ? homepages['en'].announcement || '' : '';
};
export const selectInRegionByCode = (state, code) => {
const hp = selectClientSetting(state, SETTINGS.HOMEPAGE);
const lang = selectLanguage(state);

View file

@ -303,6 +303,29 @@
}
}
// TODO: Move .announcement to `modalAnnouncements/style.scss` when structure is ready.
.announcement {
@media (min-width: $breakpoint-small) {
max-height: 90vh;
}
}
.markdown-preview--announcement {
@media (min-width: $breakpoint-small) {
max-height: 80vh;
}
overflow: hidden;
overflow-y: auto;
img {
max-width: 40%;
display: block;
margin-left: auto;
margin-right: auto;
}
}
.editor-preview {
background-color: rgba(var(--color-header-background-base), 1);
}

View file

@ -45,7 +45,7 @@ function enableBatching(reducer) {
};
}
const contentFilter = createFilter('content', ['positions', 'history']);
const contentFilter = createFilter('content', ['positions', 'history', 'lastViewedAnnouncement']);
const fileInfoFilter = createFilter('fileInfo', [
'fileListPublishedSort',
'fileListDownloadedSort',
@ -141,6 +141,7 @@ const triggerSharedStateActions = [
ACTIONS.COLLECTION_DELETE,
ACTIONS.COLLECTION_NEW,
ACTIONS.COLLECTION_PENDING,
ACTIONS.SET_LAST_VIEWED_ANNOUNCEMENT,
// MAYBE COLLECTOIN SAVE
// ACTIONS.SET_WELCOME_VERSION,
// ACTIONS.SET_ALLOW_ANALYTICS,
@ -183,6 +184,7 @@ const sharedStateFilters = {
editedCollections: { source: 'collections', property: 'edited' },
// savedCollections: { source: 'collections', property: 'saved' },
unpublishedCollections: { source: 'collections', property: 'unpublished' },
lastViewedAnnouncement: { source: 'content', property: 'lastViewedAnnouncement' },
};
const sharedStateCb = ({ dispatch, getState, syncId }) => {

View file

@ -23,3 +23,15 @@ export function getLocalStorageSummary() {
return 'inaccessible';
}
}
const localStorageAvailable = isLocalStorageAvailable();
export function getLocalStorageItem(key) {
return localStorageAvailable ? window.localStorage.getItem(key) : undefined;
}
export function setLocalStorageItem(key, value) {
if (localStorageAvailable) {
window.localStorage.setItem(key, value);
}
}

View file

@ -26,3 +26,7 @@ export function toCompactNotation(number: string | number, lang: ?string, minThr
export function stripLeadingAtSign(str: ?string) {
return str && str.charAt(0) === '@' ? str.slice(1) : str;
}
export function getSimpleStrHash(s: string) {
return String(s.split('').reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0));
}

View file

@ -1,9 +1,24 @@
const path = require('path');
const memo = {};
const loadAnnouncements = (homepageKeys) => {
const fs = require('fs');
const announcements = {};
homepageKeys.forEach((key) => {
const file = path.join(__dirname, `../dist/announcement/${key.toLowerCase()}.md`);
const announcement = fs.readFileSync(file, 'utf8');
announcements[key] = announcement ? announcement.trim() : '';
});
return announcements;
};
// this didn't seem to help.
if (!memo.homepageData) {
try {
memo.homepageData = require('../../custom/homepages/v2');
memo.announcements = loadAnnouncements(Object.keys(memo.homepageData));
} catch (err) {
console.log('getHomepageJSON:', err);
}
@ -30,7 +45,10 @@ const getHomepageJsonV2 = () => {
const v2 = {};
const homepageKeys = Object.keys(memo.homepageData);
homepageKeys.forEach((hp) => {
v2[hp] = memo.homepageData[hp];
v2[hp] = {
...memo.homepageData[hp],
announcement: memo.announcements[hp],
};
});
return v2;
};

View file

@ -68,6 +68,10 @@ const copyWebpackCommands = [
from: `${WEB_STATIC_ROOT}/pwa/`,
to: `${DIST_ROOT}/public/pwa/`,
},
{
from: `${STATIC_ROOT}/../custom/homepages/v2/announcement`,
to: `${DIST_ROOT}/announcement`,
},
];
const CUSTOM_OG_PATH = `${CUSTOM_ROOT}/v2-og.png`;