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
Original file line number Diff line number Diff line change
@@ -1,22 +1,45 @@
import React, { useContext, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { DeckGridContext } from "../../deck-manager";
import { DeckAddColumnSearchBox } from "./deck-add-column-search-box";
import { SettingsProps, UsernameDataItem } from "./common";
import { ICONS, NOTIFICATION_CONTENT_TYPES } from "../../consts";
import { ICONS, notificationContentTypesFor } from "../../consts";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Button } from "@ui/button";
import i18next from "i18next";
import { UserAvatar } from "@/features/shared";
import { PREFIX } from "@/utils/local-storage";
import { useActiveAccount } from "@/core/hooks/use-active-account";

export const DeckAddColumnNotificationsSettings = ({ deckKey }: SettingsProps) => {
const { add } = useContext(DeckGridContext);
const { activeUser } = useActiveAccount();

const [username, setUsername] = useState("");
const [tag, setTag] = useState("");
const [contentType, setContentType] = useState<string | null>(null);
const [recent, setRecent] = useLocalStorage<UsernameDataItem[]>(PREFIX + "_dnr", []);

// Favourites, bookmarks and scheduled posts are Ecency-only data and are served only to
// the account they belong to, so offering them for someone else would build a column
// that can never load.
const contentTypes = notificationContentTypesFor(username, activeUser?.username);

// Picking a self-only type and then changing the username to someone else would
// otherwise leave that choice selected and let the column be created anyway.
//
// Skipped while the username is empty: clearing the field to re-pick an account passes
// through "" on the way, and treating that as a switch would drop a valid selection
// even when the same account is chosen again.
useEffect(() => {
if (!username) {
return;
}

if (contentType && !contentTypes.some(({ type }) => type === contentType)) {
setContentType(null);
}
}, [username, contentTypes, contentType]);

return (
<div className="deck-add-column-user-settings p-3">
<div className="helper-text">{i18next.t("decks.columns.add-username-text")}</div>
Expand Down Expand Up @@ -51,7 +74,7 @@ export const DeckAddColumnNotificationsSettings = ({ deckKey }: SettingsProps) =
<>
<div className="subtitle py-3 mt-3">{i18next.t("decks.filters")}</div>
<div className="content-type-list">
{NOTIFICATION_CONTENT_TYPES.map(({ title, type }) => (
{contentTypes.map(({ title, type }) => (
<div
className={"content-type-item [&>svg]:size-8 " + (contentType === type ? "selected" : "")}
key={title}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { ShortListItemSkeleton } from "./deck-items";
import { GenericDeckWithDataColumn } from "./generic-deck-with-data-column";
import { UserDeckGridItem } from "../types";
import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd";
import { NOTIFICATION_CONTENT_TYPES, notificationsTitles } from "../consts";
import {
effectiveNotificationContentType,
notificationContentTypesFor,
notificationsTitles,
shouldPersistContentTypeCorrection
} from "../consts";
import { DeckGridContext } from "../deck-manager";
import { DeckPostViewer } from "./content-viewer";
import { DeckLoginOverlayPlaceholder } from "./deck-login-overlay-placeholder";
Expand Down Expand Up @@ -39,20 +44,59 @@ export const DeckNotificationsColumn = ({ id, settings, draggable }: Props) => {
const [isFirstLoaded, setIsFirstLoaded] = useState(false);
const [hasNextPage, setHasNextPage] = useState(true);

const { updateColumnIntervalMs } = useContext(DeckGridContext);
const { updateColumnIntervalMs, updateColumnSpecificSettings } = useContext(DeckGridContext);
const prevSettings = usePrevious(settings);

const allowedContentTypes = useMemo(
() => notificationContentTypesFor(settings.username, activeUser?.username),
[settings.username, activeUser?.username]
);

// Used for fetching straight away, before the correction below has been persisted.
const effectiveContentType = useMemo(
() =>
effectiveNotificationContentType(
settings.contentType,
settings.username,
activeUser?.username
),
[settings.contentType, settings.username, activeUser?.username]
);

// Persist the correction so the stored value, the header subtitle and the selector all
// agree, and so it survives a reload. Guarded on the active account being KNOWN: the
// store starts empty and ClientInit restores the user after mount, so writing during
// that first render would erase a valid self-only filter on an ordinary reload.
useEffect(() => {
if (
shouldPersistContentTypeCorrection(
settings.contentType,
settings.username,
activeUser?.username
)
) {
updateColumnSpecificSettings(id, { contentType: effectiveContentType });
}
}, [
settings.contentType,
settings.username,
activeUser?.username,
effectiveContentType,
id,
updateColumnSpecificSettings
]);

const fetchData = useCallback(
async (since?: ApiNotification) => {
if (data.length) {
setIsReloading(true);
}
const isAll = settings.contentType === "all";
const isAll = effectiveContentType === "all";

try {
const response = await getNotifications(
getAccessToken(activeUser!.username),
isAll ? null : (settings.contentType as NotificationFilter),
isAll ? null : (effectiveContentType as NotificationFilter),
since?.id,
settings.username
);
Expand All @@ -72,7 +116,7 @@ export const DeckNotificationsColumn = ({ id, settings, draggable }: Props) => {
setIsFirstLoaded(true);
}
},
[activeUser, data, settings.contentType, settings.username]
[activeUser, data, effectiveContentType, settings.username]
);

useEffect(() => {
Expand Down Expand Up @@ -106,7 +150,7 @@ export const DeckNotificationsColumn = ({ id, settings, draggable }: Props) => {
setUpdateIntervalMs: (v) => updateColumnIntervalMs(id, v),
additionalSettings: (
<DeckContentTypeColumnSettings
contentTypes={NOTIFICATION_CONTENT_TYPES}
contentTypes={allowedContentTypes}
settings={settings}
id={id}
/>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/app/decks/_components/consts/content-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,90 @@ export const NOTIFICATION_CONTENT_TYPES = [
type: "scheduled_published"
}
];

/**
* Notification content types that are Ecency-only rather than chain-derived, so they are
* served solely to the account they belong to.
*
* nfavorites and nbookmarks reveal who a user has favorited and what they saved, and
* scheduled_published is Ecency scheduling metadata. vision-api downgrades a request for
* another account's notifications to scope=public, which makes enotify withhold these, so
* a column built for someone else with one of these types would render permanently empty.
* They are hidden in the picker instead.
*
* `all` and `transfers` stay available: they still return their chain-derived subset.
*/
export const SELF_ONLY_NOTIFICATION_CONTENT_TYPES = [
"nfavorites",
"nbookmarks",
"scheduled_published"
];

/**
* The notification content types offered for a given column target.
*
* Used by BOTH the add-column picker and the settings of an existing column. Filtering
* only the picker left the settings able to switch a cross-account column onto a
* restricted type, which recreated the empty-column problem the filtering exists to
* prevent.
*/
export function notificationContentTypesFor(
targetUsername: string | undefined,
activeUsername: string | undefined
) {
const isSelf =
!!activeUsername &&
!!targetUsername &&
targetUsername.toLowerCase() === activeUsername.toLowerCase();

return isSelf
? NOTIFICATION_CONTENT_TYPES
: NOTIFICATION_CONTENT_TYPES.filter(
({ type }) => !SELF_ONLY_NOTIFICATION_CONTENT_TYPES.includes(type)
);
}

/**
* The content type a column should actually use, falling back to "all" when the stored
* one is not available for its target.
*
* A column persists its contentType, so one created before its target became
* cross-account, or created while signed in as a different account, can still hold a
* self-only type. Filtering the selector does not change a stored value, so without this
* the column keeps fetching a filter that returns nothing and sits permanently empty
* with its own current value missing from the selector.
*/
export function effectiveNotificationContentType(
contentType: string,
targetUsername: string | undefined,
activeUsername: string | undefined
) {
const allowed = notificationContentTypesFor(targetUsername, activeUsername);
return allowed.some(({ type }) => type === contentType) ? contentType : "all";
}

/**
* Whether a column's stored contentType should be corrected on disk.
*
* Separate from effectiveNotificationContentType because the two answer different
* questions. What to FETCH is safe to decide immediately, and "all" is the right
* temporary answer while signed out. What to PERSIST is not: the global store starts
* with no active user and ClientInit restores it after mount, so during that first
* render every column looks cross-account. Writing then would erase a valid self-only
* filter on an ordinary page reload.
*
* So: never persist until the active account is known.
*/
export function shouldPersistContentTypeCorrection(
contentType: string,
targetUsername: string | undefined,
activeUsername: string | undefined
) {
if (!activeUsername) {
return false;
}

return (
effectiveNotificationContentType(contentType, targetUsername, activeUsername) !== contentType
);
}
Loading
Loading