add feed / grid selector to feeds

This commit is contained in:
Martti Malmi
2023-11-28 21:41:53 +02:00
parent c4273b9bdf
commit ef4667c879
7 changed files with 66 additions and 36 deletions

View File

@ -1,6 +1,6 @@
import "./Timeline.css"; import "./Timeline.css";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { TaggedNostrEvent, EventKind } from "@snort/system"; import { TaggedNostrEvent, EventKind } from "@snort/system";
import { dedupeByPubkey, findTag } from "@/SnortUtils"; import { dedupeByPubkey, findTag } from "@/SnortUtils";
@ -8,7 +8,7 @@ import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineF
import useModeration from "@/Hooks/useModeration"; import useModeration from "@/Hooks/useModeration";
import { LiveStreams } from "@/Element/LiveStreams"; import { LiveStreams } from "@/Element/LiveStreams";
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { TimelineRenderer } from "@/Element/Feed/TimelineRenderer"; import { DisplayAs, DisplayAsSelector, TimelineRenderer } from "@/Element/Feed/TimelineRenderer";
export interface TimelineProps { export interface TimelineProps {
postsOnly: boolean; postsOnly: boolean;
@ -19,6 +19,7 @@ export interface TimelineProps {
now?: number; now?: number;
loadMore?: boolean; loadMore?: boolean;
noSort?: boolean; noSort?: boolean;
displayAs?: DisplayAs;
} }
/** /**
@ -33,6 +34,7 @@ const Timeline = (props: TimelineProps) => {
}; };
}, [props]); }, [props]);
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions); const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
const [displayAs, setDisplayAs] = useState<DisplayAs>("feed");
const { muted, isEventMuted } = useModeration(); const { muted, isEventMuted } = useModeration();
const filterPosts = useCallback( const filterPosts = useCallback(
@ -70,6 +72,7 @@ const Timeline = (props: TimelineProps) => {
return ( return (
<> <>
<LiveStreams evs={liveStreams} /> <LiveStreams evs={liveStreams} />
<DisplayAsSelector activeSelection={displayAs} onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)} />
<TimelineRenderer <TimelineRenderer
frags={[ frags={[
{ {
@ -80,6 +83,7 @@ const Timeline = (props: TimelineProps) => {
related={feed.related ?? []} related={feed.related ?? []}
latest={latestAuthors} latest={latestAuthors}
showLatest={t => onShowLatest(t)} showLatest={t => onShowLatest(t)}
displayAs={displayAs}
/> />
{(props.loadMore === undefined || props.loadMore === true) && ( {(props.loadMore === undefined || props.loadMore === true) && (
<div className="flex items-center"> <div className="flex items-center">

View File

@ -12,7 +12,7 @@ import { LiveStreams } from "@/Element/LiveStreams";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import useHashtagsFeed from "@/Feed/HashtagsFeed"; import useHashtagsFeed from "@/Feed/HashtagsFeed";
import { ShowMoreInView } from "@/Element/Event/ShowMore"; import { ShowMoreInView } from "@/Element/Event/ShowMore";
import { TimelineRenderer } from "@/Element/Feed/TimelineRenderer"; import { DisplayAs, DisplayAsSelector, TimelineRenderer } from "@/Element/Feed/TimelineRenderer";
export interface TimelineFollowsProps { export interface TimelineFollowsProps {
postsOnly: boolean; postsOnly: boolean;
@ -20,12 +20,14 @@ export interface TimelineFollowsProps {
noteFilter?: (ev: NostrEvent) => boolean; noteFilter?: (ev: NostrEvent) => boolean;
noteRenderer?: (ev: NostrEvent) => ReactNode; noteRenderer?: (ev: NostrEvent) => ReactNode;
noteOnClick?: (ev: NostrEvent) => void; noteOnClick?: (ev: NostrEvent) => void;
displayAs?: DisplayAs;
} }
/** /**
* A list of notes by "subject" * A list of notes by "subject"
*/ */
const TimelineFollows = (props: TimelineFollowsProps) => { const TimelineFollows = (props: TimelineFollowsProps) => {
const [displayAs, setDisplayAs] = useState<"feed" | "grid">("feed");
const [latest, setLatest] = useState(unixNow()); const [latest, setLatest] = useState(unixNow());
const feed = useSyncExternalStore( const feed = useSyncExternalStore(
cb => FollowsFeed.hook(cb, "*"), cb => FollowsFeed.hook(cb, "*"),
@ -105,6 +107,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
return ( return (
<> <>
{(props.liveStreams ?? true) && <LiveStreams evs={liveStreams} />} {(props.liveStreams ?? true) && <LiveStreams evs={liveStreams} />}
<DisplayAsSelector activeSelection={displayAs} onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)} />
<TimelineRenderer <TimelineRenderer
frags={[{ events: orderDescending(mainFeed.concat(mixinFiltered)), refTime: latest }]} frags={[{ events: orderDescending(mainFeed.concat(mixinFiltered)), refTime: latest }]}
related={reactions.data ?? []} related={reactions.data ?? []}
@ -117,6 +120,7 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
return <Link to={`/t/${e.context}`}>{`#${e.context}`}</Link>; return <Link to={`/t/${e.context}`}>{`#${e.context}`}</Link>;
} }
}} }}
displayAs={displayAs}
/> />
{sortedFeed.length > 0 && ( {sortedFeed.length > 0 && (
<ShowMoreInView onClick={async () => await FollowsFeed.loadMore(system, login, oldest ?? unixNow())} /> <ShowMoreInView onClick={async () => await FollowsFeed.loadMore(system, login, oldest ?? unixNow())} />
@ -124,4 +128,5 @@ const TimelineFollows = (props: TimelineFollowsProps) => {
</> </>
); );
}; };
export default TimelineFollows; export default TimelineFollows;

View File

@ -2,12 +2,14 @@ import { useInView } from "react-intersection-observer";
import ProfileImage from "@/Element/User/ProfileImage"; import ProfileImage from "@/Element/User/ProfileImage";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import Icon from "@/Icons/Icon"; import Icon from "@/Icons/Icon";
import {TaggedNostrEvent} from "@snort/system"; import { TaggedNostrEvent } from "@snort/system";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { TimelineFragment } from "@/Element/Feed/TimelineFragment"; import { TimelineFragment } from "@/Element/Feed/TimelineFragment";
import {transformTextCached} from "@/Hooks/useTextTransformCache"; import { transformTextCached } from "@/Hooks/useTextTransformCache";
import useImgProxy from "@/Hooks/useImgProxy"; import useImgProxy from "@/Hooks/useImgProxy";
export type DisplayAs = "grid" | "feed";
export interface TimelineRendererProps { export interface TimelineRendererProps {
frags: Array<TimelineFragment>; frags: Array<TimelineFragment>;
related: Array<TaggedNostrEvent>; related: Array<TaggedNostrEvent>;
@ -19,7 +21,7 @@ export interface TimelineRendererProps {
noteRenderer?: (ev: TaggedNostrEvent) => ReactNode; noteRenderer?: (ev: TaggedNostrEvent) => ReactNode;
noteOnClick?: (ev: TaggedNostrEvent) => void; noteOnClick?: (ev: TaggedNostrEvent) => void;
noteContext?: (ev: TaggedNostrEvent) => ReactNode; noteContext?: (ev: TaggedNostrEvent) => ReactNode;
displayAs?: "grid" | "feed"; displayAs?: DisplayAs;
} }
export function TimelineRenderer(props: TimelineRendererProps) { export function TimelineRenderer(props: TimelineRendererProps) {
@ -49,17 +51,14 @@ export function TimelineRenderer(props: TimelineRendererProps) {
className="aspect-square bg-center bg-cover cursor-pointer" className="aspect-square bg-center bg-cover cursor-pointer"
key={e.id} key={e.id}
style={{ backgroundImage: `url(${proxy(images[0].content)})` }} style={{ backgroundImage: `url(${proxy(images[0].content)})` }}
onClick={() => props.noteOnClick?.(e)} onClick={() => props.noteOnClick?.(e)}></div>
></div>
); );
}; };
const noteRenderer = props.noteRenderer || noteImageRenderer; const noteRenderer = props.noteRenderer || noteImageRenderer;
return props.frags.map(frag => ( return props.frags.map(frag => (
<div className="grid grid-cols-3 gap-1 p-1"> <div className="grid grid-cols-3 gap-1 p-1">{frag.events.map(event => noteRenderer(event))}</div>
{frag.events.map(event => noteRenderer(event))}
</div>
)); ));
}; };
@ -99,3 +98,29 @@ export function TimelineRenderer(props: TimelineRendererProps) {
</> </>
); );
} }
type DisplaySelectorProps = {
activeSelection: DisplayAs;
onSelect: (display: DisplayAs) => void;
};
export const DisplayAsSelector = ({ activeSelection, onSelect }: DisplaySelectorProps) => {
return (
<div className="flex mb-4">
<div
className={`border-highlight cursor-pointer flex justify-center flex-1 p-3 ${
activeSelection === "feed" ? "border-b border-1" : "hover:bg-nearly-bg-color text-secondary"
}`}
onClick={() => onSelect("feed")}>
<FormattedMessage defaultMessage="Feed" id="eW/Bj9" />
</div>
<div
className={`border-highlight cursor-pointer flex justify-center flex-1 p-3 ${
activeSelection === "grid" ? "border-b border-1" : "hover:bg-nearly-bg-color text-secondary"
}`}
onClick={() => onSelect("grid")}>
<FormattedMessage defaultMessage="Grid" id="HzfrYu" />
</div>
</div>
);
};

View File

@ -12,7 +12,6 @@ import TimelineFollows from "@/Element/Feed/TimelineFollows";
import { transformTextCached } from "@/Hooks/useTextTransformCache"; import { transformTextCached } from "@/Hooks/useTextTransformCache";
import Icon from "@/Icons/Icon"; import Icon from "@/Icons/Icon";
import NotificationsPage from "./Notifications/Notifications"; import NotificationsPage from "./Notifications/Notifications";
import useImgProxy from "@/Hooks/useImgProxy";
import Modal from "@/Element/Modal"; import Modal from "@/Element/Modal";
import { Thread } from "@/Element/Event/Thread"; import { Thread } from "@/Element/Event/Thread";
import { RootTabs } from "@/Element/Feed/RootTabs"; import { RootTabs } from "@/Element/Feed/RootTabs";
@ -159,36 +158,23 @@ function ArticlesCol() {
} }
function MediaCol({ setThread }: { setThread: (e: NostrLink) => void }) { function MediaCol({ setThread }: { setThread: (e: NostrLink) => void }) {
const { proxy } = useImgProxy();
return ( return (
<div> <div>
<div className="flex items-center gap-2 p-2 border-b border-border-color"> <div className="flex items-center gap-2 p-2 border-b border-border-color">
<Icon name="camera-lens" size={24} /> <Icon name="camera-lens" size={24} />
<FormattedMessage defaultMessage="Media" id="hmZ3Bz" /> <FormattedMessage defaultMessage="Media" id="hmZ3Bz" />
</div> </div>
<div className="grid grid-cols-3 gap-1 p-1"> <TimelineFollows
<TimelineFollows postsOnly={true}
postsOnly={true} liveStreams={false}
liveStreams={false} noteFilter={e => {
noteFilter={e => { const parsed = transformTextCached(e.id, e.content, e.tags);
const parsed = transformTextCached(e.id, e.content, e.tags); const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/")); return images.length > 0;
return images.length > 0; }}
}} displayAs="grid"
noteRenderer={e => { noteOnClick={e => setThread(NostrLink.fromEvent(e))}
const parsed = transformTextCached(e.id, e.content, e.tags); />
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));
return (
<div
className="aspect-square bg-center bg-cover cursor-pointer"
key={e.id}
style={{ backgroundImage: `url(${proxy(images[0].content)})` }}
onClick={() => setThread(NostrLink.fromEvent(e))}></div>
);
}}
/>
</div>
</div> </div>
); );
} }

View File

@ -534,6 +534,9 @@
"HhcAVH": { "HhcAVH": {
"defaultMessage": "You don't follow this person, click here to load media from <i>{link}</i>, or update <a><i>your preferences</i></a> to always load media from everybody." "defaultMessage": "You don't follow this person, click here to load media from <i>{link}</i>, or update <a><i>your preferences</i></a> to always load media from everybody."
}, },
"HzfrYu": {
"defaultMessage": "Grid"
},
"IEwZvs": { "IEwZvs": {
"defaultMessage": "Are you sure you want to unpin this note?" "defaultMessage": "Are you sure you want to unpin this note?"
}, },
@ -1059,6 +1062,9 @@
"eSzf2G": { "eSzf2G": {
"defaultMessage": "A single zap of {nIn} sats will allocate {nOut} sats to the zap pool." "defaultMessage": "A single zap of {nIn} sats will allocate {nOut} sats to the zap pool."
}, },
"eW/Bj9": {
"defaultMessage": "Feed"
},
"eXT2QQ": { "eXT2QQ": {
"defaultMessage": "Group Chat" "defaultMessage": "Group Chat"
}, },

View File

@ -176,6 +176,7 @@
"HWbkEK": "Clear cache and reload", "HWbkEK": "Clear cache and reload",
"HbefNb": "Open Wallet", "HbefNb": "Open Wallet",
"HhcAVH": "You don't follow this person, click here to load media from <i>{link}</i>, or update <a><i>your preferences</i></a> to always load media from everybody.", "HhcAVH": "You don't follow this person, click here to load media from <i>{link}</i>, or update <a><i>your preferences</i></a> to always load media from everybody.",
"HzfrYu": "Grid",
"IEwZvs": "Are you sure you want to unpin this note?", "IEwZvs": "Are you sure you want to unpin this note?",
"IKKHqV": "Follows", "IKKHqV": "Follows",
"IVbtTS": "Zap all {n} sats", "IVbtTS": "Zap all {n} sats",
@ -348,6 +349,7 @@
"eHAneD": "Reaction emoji", "eHAneD": "Reaction emoji",
"eJj8HD": "Get Verified", "eJj8HD": "Get Verified",
"eSzf2G": "A single zap of {nIn} sats will allocate {nOut} sats to the zap pool.", "eSzf2G": "A single zap of {nIn} sats will allocate {nOut} sats to the zap pool.",
"eW/Bj9": "Feed",
"eXT2QQ": "Group Chat", "eXT2QQ": "Group Chat",
"egib+2": "{n,plural,=1{& {n} other} other{& {n} others}}", "egib+2": "{n,plural,=1{& {n} other} other{& {n} others}}",
"fBI91o": "Zap", "fBI91o": "Zap",

View File

@ -7,6 +7,7 @@ module.exports = {
colors: { colors: {
"nearly-bg-color": "var(--nearly-bg-color)", "nearly-bg-color": "var(--nearly-bg-color)",
"border-color": "var(--border-color)", "border-color": "var(--border-color)",
highlight: "var(--highlight)",
}, },
textColor: { textColor: {
"nostr-blue": "var(--repost)", "nostr-blue": "var(--repost)",
@ -14,6 +15,7 @@ module.exports = {
"nostr-orange": "var(--zap)", "nostr-orange": "var(--zap)",
"nostr-red": "var(--heart)", "nostr-red": "var(--heart)",
"nostr-purple": "var(--highlight)", "nostr-purple": "var(--highlight)",
secondary: "var(--font-secondary-color)",
}, },
spacing: { spacing: {
px: "1px", px: "1px",