personal web client for Bluesky
typescript solidjs bluesky atcute

refactor: revamp feed info

mary.my.id f89c1510 baa28da0

verified
Changed files
+96 -140
src
+63
src/components/feeds/feed-info-prompt.tsx
··· 1 + import type { AppBskyFeedDefs } from '@atcute/client/lexicons'; 2 + 3 + import { parseAtUri } from '~/api/types/at-uri'; 4 + 5 + import { useModalContext } from '~/globals/modals'; 6 + 7 + import { formatLong } from '~/lib/intl/number'; 8 + 9 + import Avatar from '~/components/avatar'; 10 + import Button from '~/components/button'; 11 + import Divider from '~/components/divider'; 12 + import * as Prompt from '~/components/prompt'; 13 + 14 + import HeartOutlinedIcon from '../icons-central/heart-outline'; 15 + 16 + export interface FeedInfoPromptProps { 17 + /** Expected to be static */ 18 + feed: AppBskyFeedDefs.GeneratorView; 19 + } 20 + 21 + const FeedInfoPrompt = (props: FeedInfoPromptProps) => { 22 + const { close } = useModalContext(); 23 + 24 + const feed = props.feed; 25 + 26 + const authorUrl = `/${feed.creator.did}`; 27 + const feedUrl = `${authorUrl}/feeds/${parseAtUri(feed.uri).rkey}`; 28 + 29 + return ( 30 + <Prompt.Container maxWidth="md"> 31 + <div> 32 + <Avatar type="generator" src={/* @once */ feed.avatar} size={null} class="h-12 w-12" /> 33 + 34 + <p class="mt-4 text-xl font-bold">{/* @once */ feed.displayName.trim()}</p> 35 + <p class="mt-2 text-sm empty:hidden">{feed.description}</p> 36 + 37 + <div class="mt-2"> 38 + <a href={`${feedUrl}/likes`} onClick={close} class="text-de text-contrast-muted hover:underline"> 39 + {feed.likeCount === 1 40 + ? `Liked by ${formatLong(feed.likeCount)} user` 41 + : `Liked by ${formatLong(feed.likeCount ?? 0)} users`} 42 + </a> 43 + </div> 44 + 45 + <div class="mt-4 flex gap-2"> 46 + <Avatar 47 + type="user" 48 + src={/* @once */ feed.creator.avatar} 49 + href={authorUrl} 50 + onClick={close} 51 + size="xs" 52 + /> 53 + 54 + <a href={authorUrl} onClick={close} class="text-sm font-medium text-contrast-muted"> 55 + {/* @once */ feed.creator.handle} 56 + </a> 57 + </div> 58 + </div> 59 + </Prompt.Container> 60 + ); 61 + }; 62 + 63 + export default FeedInfoPrompt;
+7 -3
src/components/prompt.tsx
··· 18 18 19 19 const PromptContainer = (props: PromptContainerProps) => { 20 20 const { close, isActive } = useModalContext(); 21 - const isDesktop = useMediaQuery('(width >= 688px) and (height >= 500px)'); 21 + const isDesktop = useMediaQuery('((width >= 480px) and (height >= 500px))'); 22 22 23 23 const isDisabled = () => !!props.disabled; 24 24 ··· 43 43 } else { 44 44 return ( 45 45 <Fieldset standalone disabled={isDisabled()}> 46 - <div class="flex grow flex-col self-stretch overflow-y-auto bg-contrast-overlay/40"> 46 + <div class="flex grow flex-col items-center self-stretch overflow-y-auto bg-contrast-overlay/40"> 47 47 <div class="h-[40dvh] shrink-0"></div> 48 - <div ref={containerRef} role="menu" class="mt-auto flex flex-col rounded-t-xl bg-background p-4"> 48 + <div 49 + ref={containerRef} 50 + role="menu" 51 + class="mt-auto flex w-full max-w-120 flex-col rounded-t-xl bg-background p-4" 52 + > 49 53 {props.children} 50 54 </div> 51 55 </div>
-7
src/routes.ts
··· 206 206 return isValidDidOrHandle(params.didOrHandle); 207 207 }, 208 208 }, 209 - { 210 - path: '/:did/feeds/:rkey/info', 211 - component: lazy(() => import('./views/profile-feed-info')), 212 - validate(params) { 213 - return isValidDidOrHandle(params.did); 214 - }, 215 - }, 216 209 217 210 { 218 211 path: '/:did/lists',
-119
src/views/profile-feed-info.tsx
··· 1 - import { Match, Show, Switch, createMemo } from 'solid-js'; 2 - 3 - import type { AppBskyFeedDefs } from '@atcute/client/lexicons'; 4 - import { useQueryClient } from '@mary/solid-query'; 5 - 6 - import { ContextContentMedia } from '~/api/moderation/constants'; 7 - import { moderateGeneric } from '~/api/moderation/entities/generic'; 8 - import { moderateProfile } from '~/api/moderation/entities/profile'; 9 - import { precacheProfile } from '~/api/queries-cache/profile-precache'; 10 - import { createFeedMetaQuery } from '~/api/queries/feed'; 11 - import { makeAtUri } from '~/api/types/at-uri'; 12 - 13 - import { useParams, useTitle } from '~/lib/navigation/router'; 14 - import { inject } from '~/lib/states/singleton'; 15 - import ModerationService from '~/lib/states/singletons/moderation'; 16 - 17 - import Avatar, { getUserAvatarType } from '~/components/avatar'; 18 - import * as Boxed from '~/components/boxed'; 19 - import CircularProgressView from '~/components/circular-progress-view'; 20 - import * as Page from '~/components/page'; 21 - 22 - const FeedInfoPage = () => { 23 - const { did, rkey } = useParams(); 24 - 25 - const uri = makeAtUri(did, 'app.bsky.feed.generator', rkey); 26 - const feed = createFeedMetaQuery(() => uri); 27 - 28 - useTitle(() => { 29 - const data = feed.data; 30 - if (data) { 31 - return `Feed info (${data.displayName}) — ${import.meta.env.VITE_APP_NAME}`; 32 - } 33 - 34 - return `Feed info — ${import.meta.env.VITE_APP_NAME}`; 35 - }); 36 - 37 - return ( 38 - <> 39 - <Page.Header> 40 - <Page.HeaderAccessory> 41 - <Page.Back to={`/${did}/feeds/${rkey}`} /> 42 - </Page.HeaderAccessory> 43 - </Page.Header> 44 - 45 - <Switch> 46 - <Match when={feed.data}>{(info) => <InfoView feed={info()} />}</Match> 47 - 48 - <Match when> 49 - <CircularProgressView /> 50 - </Match> 51 - </Switch> 52 - </> 53 - ); 54 - }; 55 - 56 - export default FeedInfoPage; 57 - 58 - const InfoView = (props: { feed: AppBskyFeedDefs.GeneratorView }) => { 59 - const queryClient = useQueryClient(); 60 - 61 - const moderationOptions = inject(ModerationService); 62 - 63 - const feed = () => props.feed; 64 - const creator = () => feed().creator; 65 - 66 - const moderation = createMemo(() => { 67 - return [ 68 - ...moderateGeneric(feed(), creator().did, moderationOptions()), 69 - ...moderateProfile(creator(), moderationOptions()), 70 - ]; 71 - }); 72 - 73 - return ( 74 - <Boxed.Container> 75 - <div class="px-4"> 76 - <div class="flex gap-4"> 77 - <Avatar 78 - type="generator" 79 - src={feed().avatar} 80 - size={null} 81 - moderation={moderation()} 82 - modContext={ContextContentMedia} 83 - class="h-13 w-13" 84 - /> 85 - 86 - <div class="flex min-w-0 grow flex-col"> 87 - <p class="mb-1 mt-0.75 overflow-hidden text-ellipsis break-words text-lg font-bold leading-none"> 88 - {feed().displayName} 89 - </p> 90 - 91 - <a 92 - href={`/${creator().did}`} 93 - onClick={() => precacheProfile(queryClient, creator())} 94 - class="group mt-1 flex items-center" 95 - > 96 - <Avatar type={getUserAvatarType(creator())} src={creator().avatar} size="xs" class="mr-2" /> 97 - 98 - <span class="mr-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-contrast-muted"> 99 - {creator().handle.toLowerCase()} 100 - </span> 101 - </a> 102 - </div> 103 - </div> 104 - </div> 105 - 106 - <Boxed.Group> 107 - <Show when={feed().description?.trim()}> 108 - {(description) => ( 109 - <Boxed.List> 110 - <div class="flex flex-col whitespace-pre-wrap px-4 py-3 text-left text-sm empty:hidden"> 111 - {description()} 112 - </div> 113 - </Boxed.List> 114 - )} 115 - </Show> 116 - </Boxed.Group> 117 - </Boxed.Container> 118 - ); 119 - };
+26 -11
src/views/profile-feed.tsx
··· 11 11 12 12 import { useParams, useTitle } from '~/lib/navigation/router'; 13 13 14 + import Avatar from '~/components/avatar'; 14 15 import CircularProgressView from '~/components/circular-progress-view'; 15 16 import ErrorView from '~/components/error-view'; 17 + import FeedInfoPrompt from '~/components/feeds/feed-info-prompt'; 16 18 import FeedOverflowMenu from '~/components/feeds/feed-overflow-menu'; 17 19 import IconButton from '~/components/icon-button'; 18 - import CircleInfoOutlinedIcon from '~/components/icons-central/circle-info-outline'; 20 + import ChevronRightOutlinedIcon from '~/components/icons-central/chevron-right-outline'; 19 21 import MoreHorizOutlinedIcon from '~/components/icons-central/more-horiz-outline'; 20 22 import * as Page from '~/components/page'; 21 23 import TimelineList from '~/components/timeline/timeline-list'; ··· 44 46 <Page.Back to={`/${didOrHandle}`} /> 45 47 </Page.HeaderAccessory> 46 48 47 - <Page.Heading 49 + <Avatar type="generator" src={meta.data?.avatar} size={null} class="-ml-4 h-7 w-7" /> 50 + {/* <Page.Heading 48 51 title={(() => { 49 52 const feed = meta.data; 50 53 if (feed) { ··· 53 56 54 57 return `Feed`; 55 58 })()} 56 - /> 59 + /> */} 60 + 61 + <div class="flex min-w-0 grow"> 62 + <button 63 + disabled={!meta.data} 64 + onClick={() => { 65 + const feed = meta.data; 66 + if (!feed) { 67 + return; 68 + } 69 + 70 + openModal(() => <FeedInfoPrompt feed={feed} />); 71 + }} 72 + class="-mx-2 flex items-center gap-1 overflow-hidden rounded px-2 py-1 hover:bg-contrast-hinted/md active:bg-contrast-hinted/md-pressed" 73 + > 74 + <span class="overflow-hidden text-ellipsis whitespace-nowrap text-base font-bold"> 75 + {meta.data?.displayName.trim() || 'Feed'} 76 + </span> 77 + <ChevronRightOutlinedIcon class="-mr-1 shrink-0 rotate-90 text-lg text-contrast-muted" /> 78 + </button> 79 + </div> 57 80 58 81 <Show when={meta.data}> 59 82 {(feed) => ( 60 83 <Page.HeaderAccessory> 61 - <IconButton 62 - title="Feed information" 63 - icon={CircleInfoOutlinedIcon} 64 - onClick={() => { 65 - history.navigate(`/${didOrHandle}/feeds/${rkey}/info`); 66 - }} 67 - /> 68 - 69 84 <IconButton 70 85 title="More actions" 71 86 icon={MoreHorizOutlinedIcon}