diff --git a/README.md b/README.md index 0c08c92..86710c4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ -# rain -bluesky client +# ☔Rain + +![Vercel](https://vercelbadge.vercel.app/api/yoshiya0503/rain) +![React](https://badges.aleen42.com/src/react.svg) +![Typescript](https://github.com/aleen42/badges/raw/master/src/typescript.svg) + +**_Simple and beautiful bluesky client for web._** + +スクリーンショット 2023-10-09 15 33 54 + +# 🖥 Development + +you only try follow. + +``` +yarn +yarn dev +``` + +# 🔖 Deployment + +Now we use vercel to deploy. (hosting service for SPA) + +# 🔨 Architecture + +- _only to use react and material-UI._ +- _we use (react new feature) at all of api call._ +- _response data managed by zustand._ +- _we use typescript and reference types of atproto lexicons._ + +# ✨ Directory + +- pages -> call by react-router-dom. +- stores -> zustand state management. +- templates -> layout component and skeletons UI in loading. +- components -> UI components by using MUI. +- hooks -> separated localize data manage, and logic from components. diff --git a/index.html b/index.html index e0d1c84..da9d5e4 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - Vite + React + TS + Rain
diff --git a/package.json b/package.json index f3c3627..f07e96d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "skyline", + "name": "rain", "private": true, "version": "0.0.0", "type": "module", diff --git a/public/rain.png b/public/rain.png new file mode 100644 index 0000000..410d07f Binary files /dev/null and b/public/rain.png differ diff --git a/public/rain.svg b/public/rain.svg new file mode 100644 index 0000000..99d592f --- /dev/null +++ b/public/rain.svg @@ -0,0 +1 @@ + diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/agent.tsx b/src/agent.tsx index cc6a94c..9dc03fd 100644 --- a/src/agent.tsx +++ b/src/agent.tsx @@ -3,8 +3,8 @@ import { BskyAgent, AtpSessionEvent, AtpSessionData } from "@atproto/api"; export const agent = new BskyAgent({ service: "https://bsky.social", persistSession: (_?: AtpSessionEvent, session?: AtpSessionData) => { - localStorage.setItem("X-SKYLINE-REFRESHJWT", session?.refreshJwt || ""); - localStorage.setItem("X-SKYLINE-ACCESSJWT", session?.accessJwt || ""); + localStorage.setItem("X-RAIN-REFRESHJWT", session?.refreshJwt || ""); + localStorage.setItem("X-RAIN-ACCESSJWT", session?.accessJwt || ""); }, }); diff --git a/src/components/DialogContentFilter.tsx b/src/components/DialogContentFilter.tsx new file mode 100644 index 0000000..f52817b --- /dev/null +++ b/src/components/DialogContentFilter.tsx @@ -0,0 +1,138 @@ +import _ from "lodash"; +import { useCallback } from "react"; +import { useStore } from "@/stores"; +import Stack from "@mui/material/Stack"; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import Switch from "@mui/material/Switch"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemText from "@mui/material/ListItemText"; +import Typography from "@mui/material/Typography"; +import { AppBskyActorDefs } from "@atproto/api"; + +type Props = { + open: boolean; + preferences: AppBskyActorDefs.Preferences; + onClose: () => void; +}; + +export const DialogContentFilter = (props: Props) => { + const updatePreferences = useStore((state) => state.updatePreferences); + const adultPref = _.find(props.preferences, (p) => AppBskyActorDefs.isAdultContentPref(p)); + const contentLabelPref = _.filter(props.preferences, (p) => AppBskyActorDefs.isContentLabelPref(p)); + + const label = { + impersonation: { + title: "Impersonation", + description: "Accounts falsely claiming to be people or orgs", + }, + nsfw: { + title: "Explicit Sexual Images", + description: "i.e. pornography", + }, + gore: { + title: "Violent / Bloody", + description: "Gore, self-harm, torture", + }, + suggestive: { + title: "Sexually Suggestive", + description: "Does not include nudity", + }, + hate: { + title: "Hate Group Iconography", + description: "Images of terror groups, articles covering events, etc.", + }, + nudity: { + title: "Other Nudity", + description: "Including non-sexual and artistic", + }, + spam: { + title: "Spam", + description: "Excessive unwanted interactions", + }, + }; + + const onToggleAdult = useCallback(() => { + const preferences = _.map(props.preferences, (pref) => { + if (AppBskyActorDefs.isAdultContentPref(pref)) return { ...pref, enabled: !pref.enabled }; + return pref; + }); + updatePreferences(preferences); + }, [props, updatePreferences]); + + const onChangeFilter = useCallback( + (label: string) => (__: React.MouseEvent, visibility: string) => { + const preferences = _.map(props.preferences, (pref) => { + if (label === pref.label) return { ...pref, visibility }; + return pref; + }); + updatePreferences(preferences); + }, + [props, updatePreferences] + ); + + return ( + + Content Filter + + + + + Enable Sexual Content + + + + {_.map(contentLabelPref, (pref: AppBskyActorDefs.ContentLabelPref) => ( + + + + Hide + + + + + Warn + + + + + Show + + + + } + > + + + ))} + + + + + + + + ); +}; + +export default DialogContentFilter; diff --git a/src/components/DialogFeed.tsx b/src/components/DialogFeed.tsx new file mode 100644 index 0000000..536534e --- /dev/null +++ b/src/components/DialogFeed.tsx @@ -0,0 +1,91 @@ +import _ from "lodash"; +import { useCallback } from "react"; +import { useStore } from "@/stores"; +import Stack from "@mui/material/Stack"; +import IconButton from "@mui/material/IconButton"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import Divider from "@mui/material/Divider"; +import Avatar from "@mui/material/Avatar"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemAvatar from "@mui/material/ListItemAvatar"; +import ListItemText from "@mui/material/ListItemText"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import DialogTitle from "@mui/material/DialogTitle"; +import PushPinRoundedIcon from "@mui/icons-material/PushPinRounded"; +import DeleteForeverRoundedIcon from "@mui/icons-material/DeleteForeverRounded"; +import useFeedGenerator from "@/hooks/useFeedGenerator"; +import { AppBskyFeedDefs, AppBskyActorDefs } from "@atproto/api"; + +type Props = { + feeds: AppBskyFeedDefs.GeneratorView[]; + preferences: AppBskyActorDefs.Preferences; + open: boolean; + onClose: () => void; + onSend?: () => void; +}; + +export const DialogFeed = (props: Props) => { + const updateSavedFeedViewer = useStore((state) => state.updateSavedFeedViewer); + const { isPinned, onToggleSave, onTogglePin } = useFeedGenerator(); + + const onDelete = useCallback( + (feed: AppBskyFeedDefs.GeneratorView) => async () => { + onToggleSave(feed, props.preferences); + updateSavedFeedViewer(feed) + }, + [props, onToggleSave, updateSavedFeedViewer] + ); + + return ( + + Saved Feed + + + {_.map(props.feeds, (feed) => ( + + { + e.stopPropagation(); + onTogglePin(feed, props.preferences); + }} + > + {isPinned(feed, props.preferences) ? ( + + ) : ( + + )} + + + + + + } + > + + + + + + ))} + + + + + + + + ); +}; + +export default DialogFeed; diff --git a/src/components/DialogHandle.tsx b/src/components/DialogHandle.tsx index 1330e57..16b4e5a 100644 --- a/src/components/DialogHandle.tsx +++ b/src/components/DialogHandle.tsx @@ -25,8 +25,8 @@ type Props = { export const DialogHandle = (props: Props) => { const me = useMe(); const { open, withBackdrop } = useBackdrop(); - const { handle, onChangeHandle, onUpdateHandle } = useHandle(); - const disabled = false; + const { handle, onChangeHandle, onUpdateHandle, onClearHandle } = useHandle(); + const disabled = !handle; const onSend = async () => { withBackdrop(async () => { @@ -35,8 +35,13 @@ export const DialogHandle = (props: Props) => { }); }; + const onClear = async () => { + onClearHandle(); + props.onClose(); + }; + return ( - + theme.zIndex.drawer + 1 }} open={open}> @@ -65,7 +70,7 @@ export const DialogHandle = (props: Props) => { - + diff --git a/src/components/DialogImage.tsx b/src/components/DialogImage.tsx index 2707a22..dba5d10 100644 --- a/src/components/DialogImage.tsx +++ b/src/components/DialogImage.tsx @@ -61,7 +61,7 @@ export const DialogImage = (props: Props) => { image={image?.thumb} alt={image?.alt} /> - + {image?.alt && } void; + onSend?: () => void; +}; + +export const DialogInviteCodes = (props: Props) => { + const onCopy = useCallback( + (code: string) => () => { + navigator.clipboard.writeText(code); + }, + [] + ); + return ( + + Invite Your Friends + {_.isEmpty(props.inviteCodes) && ( + + No invite codes available... + If you want to check invite codes, you can not use app password. + + )} + + + {_.map(props.inviteCodes, (code) => ( + } onClick={onCopy(code.code)}> + + copy + + + ) : null + } + > + {code.used ? ( + + {code.code} + + } + /> + ) : ( + + {code.code} + + } + /> + )} + + ))} + + + + + + + + ); +}; + +export default DialogInviteCodes; diff --git a/src/components/DialogPasswords.tsx b/src/components/DialogPasswords.tsx new file mode 100644 index 0000000..ccfbdf7 --- /dev/null +++ b/src/components/DialogPasswords.tsx @@ -0,0 +1,110 @@ +import _ from "lodash"; +import { useCallback, useState } from "react"; +import { useStore } from "@/stores"; +import IconButton from "@mui/material/IconButton"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import Divider from "@mui/material/Divider"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemText from "@mui/material/ListItemText"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogActions from "@mui/material/DialogActions"; +import DialogTitle from "@mui/material/DialogTitle"; +import DeleteForeverRoundedIcon from "@mui/icons-material/DeleteForeverRounded"; +import useLocale from "@/hooks/useLocale"; + +type Props = { + passwords: { name: string; createdAt: string }[]; + open: boolean; + onClose: () => void; + onSend?: () => void; +}; + +export const DialogInviteCodes = (props: Props) => { + const { locale } = useLocale(); + const [password, setPassword] = useState(""); + const [created, setCreated] = useState(""); + const createAppPassword = useStore((state) => state.createAppPassword); + const deleteAppPassword = useStore((state) => state.deleteAppPassword); + + const onDelete = useCallback( + (name: string) => async () => { + deleteAppPassword(name); + }, + [deleteAppPassword] + ); + + const onAddPassword = useCallback(async () => { + const created = (await createAppPassword(password)) || ""; + setCreated(created); + }, [password, createAppPassword, setCreated]); + + const onChangePassword = useCallback( + (e: React.ChangeEvent) => { + setPassword(e.currentTarget.value); + }, + [setPassword] + ); + + const onClear = useCallback(() => { + props.onClose(); + setPassword(""); + setCreated(""); + }, [props, setPassword, setCreated]); + + return ( + + App Passwords + + + + Please enter a unique name for this App Password or use our randomly generated one. + + + + + + {created && ( + + + + {created} + + + )} + + + {_.map(props.passwords, (password) => ( + + + + } + > + + + ))} + + + + + + + + ); +}; + +export default DialogInviteCodes; diff --git a/src/components/DialogPost.tsx b/src/components/DialogPost.tsx index af2ecd6..42bcaae 100644 --- a/src/components/DialogPost.tsx +++ b/src/components/DialogPost.tsx @@ -16,7 +16,7 @@ import InputAdornment from "@mui/material/InputAdornment"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; import { grey } from "@mui/material/colors"; -import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternateRounded"; +import AttachFileRoundedIcon from "@mui/icons-material/AttachFileRounded"; import ImageList from "@mui/material/ImageList"; import ImageListItem from "@mui/material/ImageListItem"; import ImageListItemBar from "@mui/material/ImageListItemBar"; @@ -107,19 +107,11 @@ export const DialogPost = (props: Props) => { const isNotPostable = MAX_TEXT_LENGTH < text.length || !text.length; return ( - + theme.zIndex.drawer + 1 }} open={open}> - - - {props.title} - - - - - - + {props.title} {props.post && props.type === "reply" && ( @@ -149,6 +141,26 @@ export const DialogPost = (props: Props) => { ), + endAdornment: ( + + + { + e.currentTarget.value = ""; + }} + /> + + + + + + ), }} /> diff --git a/src/components/DialogProfile.tsx b/src/components/DialogProfile.tsx index 17a47ad..3f2556b 100644 --- a/src/components/DialogProfile.tsx +++ b/src/components/DialogProfile.tsx @@ -55,7 +55,7 @@ export const DialogProfile = (props: Props) => { // TODO 画像の削除が出来ない return ( - + theme.zIndex.drawer + 1 }} open={open}> diff --git a/src/components/DialogReport.tsx b/src/components/DialogReport.tsx new file mode 100644 index 0000000..45b7f7b --- /dev/null +++ b/src/components/DialogReport.tsx @@ -0,0 +1,136 @@ +import _ from "lodash"; +import { useCallback, useState } from "react"; +import { useStore } from "@/stores"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import Divider from "@mui/material/Divider"; +import Stack from "@mui/material/Stack"; +import InputAdornment from "@mui/material/InputAdornment"; +import TextField from "@mui/material/TextField"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import LabelProgress from "@/components/LabelProgress"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogActions from "@mui/material/DialogActions"; +import DialogTitle from "@mui/material/DialogTitle"; +import { ComAtprotoModerationDefs, AppBskyActorDefs, AppBskyFeedDefs } from "@atproto/api"; + +type Props = { + actor?: AppBskyActorDefs.ProfileViewDetailed; + post?: AppBskyFeedDefs.PostView; + open: boolean; + onClose: () => void; + onSend?: () => void; +}; + +const MAX_TEXT_LENGTH = 300; + +export const DialogReport = (props: Props) => { + const [reason, setReason] = useState(""); + const [reasonType, setReasonType] = useState(""); + const reportActor = useStore((state) => state.reportActor); + const reportPost = useStore((state) => state.reportPost); + + const onChangeReason = useCallback( + (e: React.ChangeEvent) => { + setReason(e.currentTarget.value); + }, + [setReason] + ); + + const onRadioReason = useCallback( + (e: React.ChangeEvent) => { + setReasonType(e.currentTarget.value); + }, + [setReasonType] + ); + + const onClear = useCallback(() => { + props.onClose(); + setReason(""); + }, [props, setReason]); + + const onSend = useCallback(() => { + props.onClose(); + setReason(""); + setReasonType(""); + if (props.actor) { + reportActor(reason, reasonType, props.actor.did); + } + if (props.post) { + reportPost(reason, reasonType, props.post.cid, props.post.uri); + } + }, [props, reason, setReason, reasonType, setReasonType, reportActor, reportPost]); + + const isNotSendable = MAX_TEXT_LENGTH < reason.length || !reason.length; + + const reportMenus = props.post + ? [ + { value: ComAtprotoModerationDefs.REASONMISLEADING, label: "Misleading Account" }, + { value: ComAtprotoModerationDefs.REASONSPAM, label: "Spam" }, + { value: ComAtprotoModerationDefs.REASONVIOLATION, label: "Violates" }, + { value: ComAtprotoModerationDefs.REASONRUDE, label: "Rude" }, + { value: ComAtprotoModerationDefs.REASONSEXUAL, label: "Sexual" }, + { value: ComAtprotoModerationDefs.REASONOTHER, label: "Other" }, + ] + : [ + { value: ComAtprotoModerationDefs.REASONMISLEADING, label: "Misleading Account" }, + { value: ComAtprotoModerationDefs.REASONSPAM, label: "Spam" }, + { value: ComAtprotoModerationDefs.REASONVIOLATION, label: "Violates" }, + ]; + + return ( + + Report Account + + + + + {_.map(reportMenus, (menu, key) => ( + } label={menu.label} /> + ))} + + + + + + + + ), + }} + /> + + + + + + + + ); +}; + +export default DialogReport; diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 1e83698..f2fb176 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -18,6 +18,7 @@ import RssFeedRoundedIcon from "@mui/icons-material/RssFeedRounded"; import PushPinRoundedIcon from "@mui/icons-material/PushPinRounded"; import Text from "@/components/Text"; import FeedBrief from "@/components/FeedBrief"; +import useFeedGenerator from "@/hooks/useFeedGenerator"; import { AppBskyFeedDefs, AppBskyActorDefs } from "@atproto/api"; const FeedAccordion = styled((props: AccordionProps) => )( @@ -54,31 +55,7 @@ export const Feed = (props: Props) => { navigate(uri); }, [props, navigate]); - const feedPref = _.find(props.preferences, (p) => AppBskyActorDefs.isSavedFeedsPref(p)); - const isSaved = - AppBskyActorDefs.isSavedFeedsPref(feedPref) && _.find(feedPref.saved, (uri) => props.feed.uri === uri); - const isPinned = - AppBskyActorDefs.isSavedFeedsPref(feedPref) && _.find(feedPref.pinned, (uri) => props.feed.uri === uri); - - const onToggleSave = useCallback(() => { - if (!AppBskyActorDefs.isSavedFeedsPref(feedPref)) return; - const preferences = _.map(props.preferences, (p) => { - if (!AppBskyActorDefs.isSavedFeedsPref(p)) return p; - if (isSaved) return { ...p, saved: _.reject(feedPref.saved, (uri) => uri === props.feed.uri) }; - return { ...p, saved: _.concat(feedPref.saved, props.feed.uri) }; - }); - props.updatePreferences(preferences); - }, [props, feedPref, isSaved]); - - const onTogglePin = useCallback(() => { - if (!AppBskyActorDefs.isSavedFeedsPref(feedPref)) return; - const preferences = _.map(props.preferences, (p) => { - if (!AppBskyActorDefs.isSavedFeedsPref(p)) return p; - if (isPinned) return { ...p, pinned: _.reject(feedPref.pinned, (uri) => uri === props.feed.uri) }; - return { ...p, pinned: _.concat(feedPref.pinned, props.feed.uri) }; - }); - props.updatePreferences(preferences); - }, [props, feedPref, isPinned]); + const { isSaved, isPinned, onToggleSave, onTogglePin } = useFeedGenerator(); return ( @@ -112,20 +89,28 @@ export const Feed = (props: Props) => { color="primary" onClick={(e) => { e.stopPropagation(); - onToggleSave(); + onToggleSave(props.feed, props.preferences); }} > - {isSaved ? : } + {isSaved(props.feed, props.preferences) ? ( + + ) : ( + + )} { e.stopPropagation(); - onTogglePin(); + onTogglePin(props.feed, props.preferences); }} > - {isPinned ? : } + {isPinned(props.feed, props.preferences) ? ( + + ) : ( + + )} @@ -133,7 +118,7 @@ export const Feed = (props: Props) => { {props.feed.likeCount} - + {props.feed.description} @@ -147,7 +132,7 @@ export const Feed = (props: Props) => { )) ) : ( - + )} diff --git a/src/components/FeedBrief.tsx b/src/components/FeedBrief.tsx index c8415ec..81d0e6e 100644 --- a/src/components/FeedBrief.tsx +++ b/src/components/FeedBrief.tsx @@ -1,11 +1,10 @@ -import { formatDistanceToNowStrict } from "date-fns"; -import { ja } from "date-fns/locale"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { grey } from "@mui/material/colors"; import ProfileHeader from "@/components/ProfileHeader"; import Text from "@/components/Text"; import Attachments from "@/components/Attachments"; +import useLocale from "@/hooks/useLocale"; import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; type Props = { @@ -13,13 +12,13 @@ type Props = { }; export const FeedBrief = (props: Props) => { - const dateLabel = formatDistanceToNowStrict(Date.parse(props.brief.indexedAt), { locale: ja }); + const { fromNow } = useLocale(); return ( - {dateLabel} + {fromNow(props.brief.indexedAt)} diff --git a/src/components/FeedGenerator.tsx b/src/components/FeedGenerator.tsx index fdcfb3f..d37eb5c 100644 --- a/src/components/FeedGenerator.tsx +++ b/src/components/FeedGenerator.tsx @@ -1,6 +1,5 @@ -import _ from "lodash"; import { useCallback } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { grey, pink } from "@mui/material/colors"; import Stack from "@mui/material/Stack"; import IconButton from "@mui/material/IconButton"; @@ -10,10 +9,12 @@ import CardContent from "@mui/material/CardContent"; import Typography from "@mui/material/Typography"; import FeedRoundedIcon from "@mui/icons-material/FeedRounded"; import FavoriteIcon from "@mui/icons-material/FavoriteRounded"; +import FavoriteBorderRoundedIcon from "@mui/icons-material/FavoriteBorderRounded"; import ShareIcon from "@mui/icons-material/ShareRounded"; import RssFeedRoundedIcon from "@mui/icons-material/RssFeedRounded"; import PushPinRoundedIcon from "@mui/icons-material/PushPinRounded"; import Text from "@/components/Text"; +import useFeedGenerator from "@/hooks/useFeedGenerator"; import { AppBskyFeedDefs, AppBskyActorDefs } from "@atproto/api"; type Props = { @@ -24,45 +25,16 @@ type Props = { export const FeedGenerator = (props: Props) => { const navigate = useNavigate(); + const location = useLocation(); const onViewCreator = useCallback(() => { const uri = `/profile/${props.feed.creator.handle}`; - navigate(uri); - }, [props, navigate]); + if (location.pathname !== uri) { + navigate(uri); + } + }, [props, navigate, location]); - const feedPref = _.find(props.preferences, (p) => AppBskyActorDefs.isSavedFeedsPref(p)); - const isSaved = - AppBskyActorDefs.isSavedFeedsPref(feedPref) && _.find(feedPref.saved, (uri) => props.feed.uri === uri); - const isPinned = - AppBskyActorDefs.isSavedFeedsPref(feedPref) && _.find(feedPref.pinned, (uri) => props.feed.uri === uri); - - const onToggleSave = useCallback(() => { - if (!AppBskyActorDefs.isSavedFeedsPref(feedPref)) return; - const preferences = _.map(props.preferences, (p) => { - if (!AppBskyActorDefs.isSavedFeedsPref(p)) return p; - if (isSaved) return { ...p, saved: _.reject(feedPref.saved, (uri) => uri === props.feed.uri) }; - return { ...p, saved: _.concat(feedPref.saved, props.feed.uri) }; - }); - props.updatePreferences(preferences); - }, [props, feedPref, isSaved]); - - const onTogglePin = useCallback(() => { - if (!AppBskyActorDefs.isSavedFeedsPref(feedPref)) return; - const preferences = _.map(props.preferences, (p) => { - if (!AppBskyActorDefs.isSavedFeedsPref(p)) return p; - if (isPinned) return { ...p, pinned: _.reject(feedPref.pinned, (uri) => uri === props.feed.uri) }; - return { ...p, pinned: _.concat(feedPref.pinned, props.feed.uri) }; - }); - props.updatePreferences(preferences); - }, [props, feedPref, isPinned]); - - const onClickShare = useCallback(() => { - const url = _.chain(props.feed.uri) - .replace("at://", "https://bsky.app/profile/") - .replace("/app.bsky.feed.generator/", "/feed/") - .value(); - navigator.clipboard.writeText(url); - }, [props]); + const { isSaved, isPinned, onToggleLike, onToggleSave, onTogglePin, onShare } = useFeedGenerator(); return ( @@ -85,7 +57,18 @@ export const FeedGenerator = (props: Props) => { - + { + e.stopPropagation(); + onToggleLike(props.feed); + }} + > + {props.feed.viewer?.like ? ( + + ) : ( + + )} + {props.feed.likeCount} Likes @@ -95,27 +78,35 @@ export const FeedGenerator = (props: Props) => { color="primary" onClick={(e) => { e.stopPropagation(); - onToggleSave(); + onToggleSave(props.feed, props.preferences); }} > - {isSaved ? : } + {isSaved(props.feed, props.preferences) ? ( + + ) : ( + + )} { e.stopPropagation(); - onTogglePin(); + onTogglePin(props.feed, props.preferences); }} > - {isPinned ? : } + {isPinned(props.feed, props.preferences) ? ( + + ) : ( + + )} { e.stopPropagation(); - onClickShare(); + onShare(props.feed); }} > diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx index bb2c635..bd4b61d 100644 --- a/src/components/NotFound.tsx +++ b/src/components/NotFound.tsx @@ -5,7 +5,7 @@ import { grey } from "@mui/material/colors"; import { useTheme } from "@mui/material/styles"; type Props = { - type?: "thread" | "liked" | "reposted"; + type?: "thread" | "liked" | "reposted" | "search"; }; export const NotFound = (props: Props) => { @@ -17,13 +17,16 @@ export const NotFound = (props: Props) => { {props.type === "reposted" && "There area no repost"} {props.type === "liked" && "There are no like"} {props.type === "thread" && "Post Not Found"} + {props.type === "search" && "No search results"} {!props.type && "404 Not Found"} - - - Return To Home - - + {props.type !== "search" && ( + + + Return To Home + + + )} ); }; diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index b0c2f4f..b4f190a 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -1,13 +1,12 @@ import _ from "lodash"; import { useCallback } from "react"; -import { formatDistanceToNowStrict } from "date-fns"; -import { ja } from "date-fns/locale"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; import MuteIcon from "@mui/icons-material/VolumeOff"; import ShareIcon from "@mui/icons-material/Share"; +import ReportIcon from "@mui/icons-material/ReportRounded"; import { grey } from "@mui/material/colors"; import NotificationAvatars from "@/components/NotificationAvatars"; import NotificationImages from "@/components/NotificationImages"; @@ -17,6 +16,7 @@ import PostActions from "@/components/PostActions"; import Attachments from "@/components/Attachments"; import Text from "@/components/Text"; import usePost from "@/hooks/usePost"; +import useLocale from "@/hooks/useLocale"; import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, AppBskyNotificationListNotifications } from "@atproto/api"; import { AppBskyEmbedImages } from "@atproto/api"; @@ -25,12 +25,14 @@ type Props = { otherAuthors: AppBskyActorDefs.ProfileView[]; onOpenPost?: (post: AppBskyFeedDefs.PostView, type: "reply" | "quote") => void; onOpenImage?: (images: AppBskyEmbedImages.ViewImage[]) => void; + onOpenReport?: (post: AppBskyFeedDefs.PostView) => void; reasonSubject?: AppBskyFeedDefs.PostView; reasonReply?: AppBskyFeedDefs.PostView; }; export const Post = (props: Props) => { const { onShare, onViewThread } = usePost(); + const { fromNow } = useLocale(); // TODO フォローしてきた人のミニアバターを詳細にして出すとかいいかもしれない const menuItems = [ @@ -50,9 +52,16 @@ export const Post = (props: Props) => { console.log("mute"); }, }, + { + name: "report", + icon: , + label: "Report", + action: () => { + props.onOpenReport && props.reasonReply && props.onOpenReport(props.reasonReply); + }, + }, ]; - const dateLabel = formatDistanceToNowStrict(Date.parse(props.notification.indexedAt), { locale: ja }); const multiAuthorMessage = 1 <= _.size(props.otherAuthors) ? `他${_.size(props.otherAuthors)}人 ` : ""; const message = `${props.notification.author.handle} ${multiAuthorMessage}が${props.notification.reason}しました`; @@ -79,11 +88,9 @@ export const Post = (props: Props) => { - {dateLabel} + {fromNow(props.notification.indexedAt)} - {_.includes(["reply", "quote", "mention"], props.notification.reason) && ( - - )} + {_.includes(["reply", "quote", "mention"], props.notification.reason) && } {!_.includes(["reply", "quote", "mention"], props.notification.reason) && ( diff --git a/src/components/Post.tsx b/src/components/Post.tsx index b0f5a87..8804480 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -1,5 +1,3 @@ -import { formatDistanceToNowStrict } from "date-fns"; -import { ja } from "date-fns/locale"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; @@ -7,6 +5,7 @@ import MuteIcon from "@mui/icons-material/VolumeOff"; import DeleteIcon from "@mui/icons-material/DeleteOutline"; import ShareIcon from "@mui/icons-material/Share"; import LoopIcon from "@mui/icons-material/Loop"; +import ReportIcon from "@mui/icons-material/ReportRounded"; import { grey, green } from "@mui/material/colors"; import AvatarThread from "@/components/AvatarThread"; import ProfileHeader from "@/components/ProfileHeader"; @@ -16,48 +15,87 @@ import PostStats from "@/components/PostStats"; import Text from "@/components/Text"; import Attachments from "@/components/Attachments"; import usePost from "@/hooks/usePost"; -import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyEmbedImages } from "@atproto/api"; +import useLocale from "@/hooks/useLocale"; +import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyEmbedImages, AppBskyActorDefs } from "@atproto/api"; type Props = { + me?: AppBskyActorDefs.ProfileView; post: AppBskyFeedDefs.PostView; onOpenPost?: (post: AppBskyFeedDefs.PostView, type: "reply" | "quote") => void; onOpenImage?: (images: AppBskyEmbedImages.ViewImage[]) => void; + onOpenReport?: (post: AppBskyFeedDefs.PostView) => void; reason?: AppBskyFeedDefs.ReasonRepost | { [k: string]: unknown; $type: string }; hasReply?: boolean; showStats?: boolean; }; +// TODO mute threads export const Post = (props: Props) => { const { onDeletePost, onShare, onViewThread } = usePost(); - const menuItems = [ - { - name: "share", - label: "Share", - icon: , - action: () => { - onShare(props.post); - }, - }, - { - name: "mute", - label: props.post?.viewer?.muted ? "Unmute" : "Mute", - icon: , - action: () => { - console.log("mute"); - }, - }, - // TODO 自分の投稿と挙動が違う - { - name: "delete", - label: "Delete Post", - icon: , - action: () => { - onDeletePost(props.post); - }, - }, - ]; + const { fromNow } = useLocale(); - const dateLabel = formatDistanceToNowStrict(Date.parse(props.post.indexedAt), { locale: ja }); + const isMe = props.me?.did === props.post.author.did; + const menuItems = isMe + ? [ + { + name: "share", + label: "Share", + icon: , + action: () => { + onShare(props.post); + }, + }, + { + name: "mute", + label: props.post?.viewer?.muted ? "Unmute" : "Mute", + icon: , + action: () => { + console.log("mute"); + }, + }, + { + name: "report", + icon: , + label: "Report", + action: () => { + props.onOpenReport && props.onOpenReport(props.post); + }, + }, + { + name: "delete", + label: "Delete Post", + icon: , + action: () => { + onDeletePost(props.post); + }, + }, + ] + : [ + { + name: "share", + label: "Share", + icon: , + action: () => { + onShare(props.post); + }, + }, + { + name: "mute", + label: props.post?.viewer?.muted ? "Unmute" : "Mute", + icon: , + action: () => { + console.log("mute"); + }, + }, + { + name: "report", + icon: , + label: "Report", + action: () => { + props.onOpenReport && props.onOpenReport(props.post); + }, + }, + ]; return ( @@ -73,7 +111,7 @@ export const Post = (props: Props) => { - {dateLabel} + {fromNow(props.post.indexedAt)} diff --git a/src/components/PostActions.tsx b/src/components/PostActions.tsx index 6982a81..67d0691 100644 --- a/src/components/PostActions.tsx +++ b/src/components/PostActions.tsx @@ -47,7 +47,7 @@ export const PostActions = (props: Props) => { onClick={onToggleLike} > {props.post.viewer?.like ? ( - + ) : ( @@ -59,7 +59,7 @@ export const PostActions = (props: Props) => { onClick={onToggleRePost} > {props.post.viewer?.repost ? ( - + ) : ( diff --git a/src/components/PostQuote.tsx b/src/components/PostQuote.tsx index afa116a..5896176 100644 --- a/src/components/PostQuote.tsx +++ b/src/components/PostQuote.tsx @@ -1,6 +1,4 @@ import _ from "lodash"; -import { formatDistanceToNowStrict } from "date-fns"; -import { ja } from "date-fns/locale"; import Card from "@mui/material/Card"; import Stack from "@mui/material/Stack"; import CardContent from "@mui/material/CardContent"; @@ -13,6 +11,7 @@ import PostImages from "@/components/PostImages"; import PostFeed from "@/components/PostFeed"; import Text from "@/components/Text"; import usePost from "@/hooks/usePost"; +import useLocale from "@/hooks/useLocale"; import { AppBskyFeedDefs, AppBskyFeedPost, @@ -29,8 +28,7 @@ type Props = { export const PostQuote = (props: Props) => { const { onViewThread } = usePost(); - - const dateLabel = formatDistanceToNowStrict(Date.parse(props.record.indexedAt), { locale: ja }); + const { fromNow } = useLocale(); return ( @@ -46,7 +44,7 @@ export const PostQuote = (props: Props) => { - {dateLabel} + {fromNow(props.record.indexedAt)} diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 57e5b9c..ed231ff 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; @@ -19,6 +19,7 @@ import ShareIcon from "@mui/icons-material/ShareRounded"; import EditIcon from "@mui/icons-material/EditRounded"; import DropDownMenu from "@/components/DropDownMenu"; import DialogProfile from "@/components/DialogProfile"; +import DialogReport from "@/components/DialogReport"; import Text from "@/components/Text"; import useSocial from "@/hooks/useSocial"; import useDialog from "@/hooks/useDialog"; @@ -26,7 +27,6 @@ import useMe from "@/hooks/useMe"; import { AppBskyActorDefs } from "@atproto/api"; // TODO Add to list -// TODO moderation report type Props = { actor: AppBskyActorDefs.ProfileViewDetailed; }; @@ -34,8 +34,10 @@ type Props = { export const Profile = (props: Props) => { const theme = useTheme(); const navigate = useNavigate(); + const location = useLocation(); const me = useMe(); - const [isOpen, openProfileDialog, closeProfileDialog] = useDialog(); + const [isOpenProfile, openProfileDialog, closeProfileDialog] = useDialog(); + const [isOpenReport, openReportDialog, closeReportDialog] = useDialog(); const { onFollow, onUnFollow, onMute, onUnMute, onBlock, onUnBlock, onShare } = useSocial(); const onToggleFollow = useCallback(() => { @@ -56,8 +58,10 @@ export const Profile = (props: Props) => { const onViewFollow = useCallback(() => { const uri = `/profile/${props.actor.handle}/follows`; - navigate(uri); - }, [props, navigate]); + if (location.pathname !== uri) { + navigate(uri); + } + }, [props, navigate, location]); const isMe = me.did === props.actor.did; const muteLabel = props.actor?.viewer?.muted ? "Unmute" : "Mute"; @@ -69,7 +73,7 @@ export const Profile = (props: Props) => { { name: "add_to_list", icon: , label: "Add To List", action: onClickShare }, { name: "mute", icon: , label: muteLabel, action: onToggleMute }, { name: "block", icon: , label: blockLabel, action: onToggleBlock }, - { name: "report", icon: , label: "Report", action: onToggleMute }, + { name: "report", icon: , label: "Report", action: openReportDialog }, ]; return ( @@ -173,12 +177,13 @@ export const Profile = (props: Props) => { posts - + {props.actor.description} - + + ); }; diff --git a/src/components/ProfileHeader.tsx b/src/components/ProfileHeader.tsx index ef9c7cd..8e4a0fc 100644 --- a/src/components/ProfileHeader.tsx +++ b/src/components/ProfileHeader.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import Typography from "@mui/material/Typography"; import Avatar from "@mui/material/Avatar"; import Stack from "@mui/material/Stack"; @@ -13,6 +13,7 @@ type Props = { export const ProfileHeader = (props: Props) => { const navigate = useNavigate(); + const location = useLocation(); let sx = { width: 42, height: 42 }; if (props.size === "small") { @@ -33,7 +34,9 @@ export const ProfileHeader = (props: Props) => { onClick={(e) => { e.stopPropagation(); const uri = `/profile/${props.profile.handle}`; - navigate(uri); + if (location.pathname !== uri) { + navigate(uri); + } }} > {!props.disableAvatar && } diff --git a/src/components/Search.tsx b/src/components/Search.tsx new file mode 100644 index 0000000..7d37ffa --- /dev/null +++ b/src/components/Search.tsx @@ -0,0 +1,32 @@ +import TextField from "@mui/material/TextField"; +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; +import InputAdornment from "@mui/material/InputAdornment"; +import useSearch from "@/hooks/useSearch"; + +export const Search = () => { + const { keyword, isFocus, onFocus, onSearch, onChangeSearch } = useSearch(); + + return ( + + + + ), + }} + /> + ); +}; + +export default Search; diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx new file mode 100644 index 0000000..49a9fa3 --- /dev/null +++ b/src/components/SideBar.tsx @@ -0,0 +1,114 @@ +import _ from "lodash"; +import { useCallback } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import Stack from "@mui/material/Stack"; +import Paper from "@mui/material/Paper"; +import Divider from "@mui/material/Divider"; +import Avatar from "@mui/material/Avatar"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemAvatar from "@mui/material/ListItemAvatar"; +import ListItemText from "@mui/material/ListItemText"; +import ListItemButton from "@mui/material/ListItemButton"; +import Search from "@/components/Search"; +import useSuggestion from "@/hooks/useSuggestion"; +import usePreference from "@/hooks/usePreference"; +import useSocial from "@/hooks/useSocial"; +import { AppBskyActorDefs, AppBskyFeedDefs } from "@atproto/api"; + +export const SideBar = () => { + const navigate = useNavigate(); + const location = useLocation(); + const suggestions = useSuggestion(); + const { preferences, savedFeeds } = usePreference(); + const { onFollow } = useSocial(); + + const feedPref = _.find(preferences, (p) => AppBskyActorDefs.isSavedFeedsPref(p)); + const pinned = (AppBskyActorDefs.isSavedFeedsPref(feedPref) && feedPref?.pinned) || []; + const pinnedFeeds = _.filter(savedFeeds, (f) => _.includes(pinned, f.uri)); + + const onViewFeedGenerator = useCallback( + (feed: AppBskyFeedDefs.GeneratorView) => () => { + const uri = _.chain(feed.uri).replace("at://", "/profile/").replace("app.bsky.feed.generator", "feed").value(); + navigate(uri); + }, + [navigate] + ); + + const onViewProfile = useCallback( + (actor: AppBskyActorDefs.ProfileView) => () => { + const uri = `/profile/${actor.handle}`; + if (location.pathname !== uri) { + navigate(uri); + } + }, + [navigate, location] + ); + + const actors = _.take(suggestions, 4); + + return ( + + + + + + Feeds + + + {_.map(pinnedFeeds, (feed, key) => ( + + + + + + + + + ))} + + + + + + Suggested Follows + + + {_.isEmpty(actors) && ( + + + No Suggestions, Please Reload The Page + + + )} + {_.map(actors, (actor, key) => ( + onFollow(actor)} + > + Follow + + } + disablePadding + > + + + + + + + + ))} + + + + ); +}; + +export default SideBar; diff --git a/src/components/SideMenu.tsx b/src/components/SideMenu.tsx index 94998e9..60f11fd 100644 --- a/src/components/SideMenu.tsx +++ b/src/components/SideMenu.tsx @@ -1,7 +1,11 @@ import _ from "lodash"; -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; +import Avatar from "@mui/material/Avatar"; +import Paper from "@mui/material/Paper"; +import Drawer from "@mui/material/Drawer"; import Button from "@mui/material/Button"; +import Fab from "@mui/material/Fab"; +import IconButton from "@mui/material/IconButton"; import List from "@mui/material/List"; import ListItem from "@mui/material/ListItem"; import ListItemIcon from "@mui/material/ListItemIcon"; @@ -10,7 +14,7 @@ import ListItemButton from "@mui/material/ListItemButton"; import Badge from "@mui/material/Badge"; import Home from "@mui/icons-material/HomeRounded"; import Search from "@mui/icons-material/SearchRounded"; -import Feed from "@mui/icons-material/FeedRounded"; +import Tag from "@mui/icons-material/TagRounded"; import Notifications from "@mui/icons-material/NotificationsRounded"; import AccountCircle from "@mui/icons-material/AccountCircleRounded"; import Settings from "@mui/icons-material/SettingsRounded"; @@ -18,32 +22,32 @@ import Create from "@mui/icons-material/CreateRounded"; import ProfileHeader from "@/components/ProfileHeader"; import DialogPost from "@/components/DialogPost"; import useMe from "@/hooks/useMe"; -import useNotification from "@/hooks/useNotification"; +import useRealtime from "@/hooks/useRealtime"; import useDialog from "@/hooks/useDialog"; -const INTERVAL = 10 * 1000; +type Props = { + type?: "drawer" | "paper"; +}; -export const SideMenu = () => { +export const SideMenu = (props: Props) => { const me = useMe(); - const { unreadCount, countUnreadNotifications } = useNotification(); + const { unreadCount, unreadTimeline } = useRealtime(); const navigate = useNavigate(); + const location = useLocation(); const [isOpen, openPostDialog, closePostDialog] = useDialog(); - /* - useEffect(() => { - const interval = setInterval(async () => { - await countUnreadNotifications(); - }, INTERVAL); - return () => { - clearInterval(interval); - }; - }, [countUnreadNotifications]); - */ - const menus = [ - { name: "Home", icon: , href: "/" }, + { + name: "Home", + icon: ( + + + + ), + href: "/", + }, { name: "Search", icon: , href: "/search" }, - { name: "Feeds", icon: , href: "/feeds" }, + { name: "Feeds", icon: , href: "/feeds" }, { name: "Notifications", icon: ( @@ -58,34 +62,84 @@ export const SideMenu = () => { ]; const onClickMenu = (href: string) => () => { - navigate(href); + if (location.pathname !== href) { + navigate(href); + } }; + if (props.type === "drawer") { + const DRAWER_WIDTH = 72; + return ( + + + + { + e.stopPropagation(); + const uri = `/profile/${me.handle}`; + if (location.pathname !== uri) { + navigate(uri); + } + }} + /> + + {_.map(menus, (menu, key) => ( + + {menu.icon} + + ))} + + + + + + + + + ); + } + return ( - - - - - {_.map(menus, (menu, key) => ( - - - {menu.icon} - - + + + + + + {_.map(menus, (menu, key) => ( + + + {menu.icon} + + + + ))} + + + - ))} - - - - - + + ); }; diff --git a/src/components/Text.tsx b/src/components/Text.tsx index 216d17d..c0ef2a5 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -12,6 +12,7 @@ type RenderProps = { content: ReactNode; }; +// TODO replace by rich text export const Text = (props: Props) => { const render = ({ attributes, content }: RenderProps) => { return ( diff --git a/src/components/UnreadPosts.tsx b/src/components/UnreadPosts.tsx new file mode 100644 index 0000000..0a3bc56 --- /dev/null +++ b/src/components/UnreadPosts.tsx @@ -0,0 +1,56 @@ +import _ from "lodash"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import AvatarGroup from "@mui/material/AvatarGroup"; +import Avatar from "@mui/material/Avatar"; +import { AppBskyFeedDefs } from "@atproto/api"; + +type Props = { + unread: AppBskyFeedDefs.FeedViewPost[]; + onClick?: () => void; +}; + +export const UnreadPosts = (props: Props) => { + const authors = _.chain(props.unread) + .map((item) => item.post.author) + .uniqBy((item) => item.handle) + .value(); + if (!props.onClick) { + return ( + + + {_.map(authors, (author, key) => ( + + ))} + + about {_.size(props.unread)} new posts + + ); + } + + return ( + + + + ); +}; + +export default UnreadPosts; diff --git a/src/containers/FeedGeneratorContainer.tsx b/src/containers/FeedGeneratorContainer.tsx index 21ff15a..ed7dad7 100644 --- a/src/containers/FeedGeneratorContainer.tsx +++ b/src/containers/FeedGeneratorContainer.tsx @@ -88,7 +88,7 @@ export const FeedGeneratorContainer = (props: Props) => { ))} - + diff --git a/src/containers/FeedsContainer.tsx b/src/containers/FeedsContainer.tsx index fc39830..c276594 100644 --- a/src/containers/FeedsContainer.tsx +++ b/src/containers/FeedsContainer.tsx @@ -51,7 +51,7 @@ export const FeedsContainer = (props: Props) => { /> ))} - + ); }; diff --git a/src/containers/FollowsContainer.tsx b/src/containers/FollowsContainer.tsx index a4c5dc9..f8f2e8b 100644 --- a/src/containers/FollowsContainer.tsx +++ b/src/containers/FollowsContainer.tsx @@ -52,7 +52,7 @@ export const FollowsContainer = (props: Props) => { ))} - + ); }; diff --git a/src/containers/NotificationsContainer.tsx b/src/containers/NotificationsContainer.tsx index 3747899..e539fd3 100644 --- a/src/containers/NotificationsContainer.tsx +++ b/src/containers/NotificationsContainer.tsx @@ -10,6 +10,7 @@ import ScrollLayout from "@/templates/ScrollLayout"; import Notification from "@/components/Notification"; import DialogPost from "@/components/DialogPost"; import DialogImage from "@/components/DialogImage"; +import DialogReport from "@/components/DialogReport"; import useDialog from "@/hooks/useDialog"; import { AppBskyFeedDefs, AppBskyEmbedImages } from "@atproto/api"; @@ -21,6 +22,7 @@ export const NotificationContainer = () => { const listNotifications = useStore((state) => state.listNotifications); const [isOpen, openPostDialog, closePostDialog] = useDialog(); const [isOpenImage, openImageDialog, closeImageDialog] = useDialog(); + const [isOpenReport, openReportDialog, closeReportDialog] = useDialog(); const [post, setPost] = useState(); const [images, setImages] = useState(); const [type, setType] = useState<"reply" | "quote">(); @@ -52,6 +54,14 @@ export const NotificationContainer = () => { [openImageDialog, setImages] ); + const onOpenReport = useCallback( + (post: AppBskyFeedDefs.PostView) => { + setPost(post); + openReportDialog(); + }, + [openReportDialog, setPost] + ); + const title = type === "reply" ? "Reply" : "Quote"; return ( @@ -71,6 +81,7 @@ export const NotificationContainer = () => { reasonReply={reasonReply} onOpenPost={onOpenPost} onOpenImage={onOpenImage} + onOpenReport={onOpenReport} /> @@ -78,9 +89,10 @@ export const NotificationContainer = () => { ); })} - + + ); }; diff --git a/src/containers/PostThreadsContainer.tsx b/src/containers/PostThreadsContainer.tsx index 3c2d4b1..dd80983 100644 --- a/src/containers/PostThreadsContainer.tsx +++ b/src/containers/PostThreadsContainer.tsx @@ -8,8 +8,10 @@ import CenterLayout from "@/templates/CenterLayout"; import Post from "@/components/Post"; import DialogPost from "@/components/DialogPost"; import DialogImage from "@/components/DialogImage"; +import DialogReport from "@/components/DialogReport"; import NotFound from "@/components/NotFound"; import useDialog from "@/hooks/useDialog"; +import useMe from "@/hooks/useMe"; import { AppBskyFeedDefs, AppBskyEmbedImages } from "@atproto/api"; type Props = { @@ -18,6 +20,7 @@ type Props = { }; export const PostThreadsContainer = (props: Props) => { + const me = useMe(); const thread = useStore((state) => state.thread); const threadSubject = useStore((state) => state.threadSubject); const threadParent = useStore((state) => state.threadParent); @@ -26,6 +29,7 @@ export const PostThreadsContainer = (props: Props) => { const getPostThread = useStore((state) => state.getPostThread); const [isOpen, openPostDialog, closePostDialog] = useDialog(); const [isOpenImage, openImageDialog, closeImageDialog] = useDialog(); + const [isOpenReport, openReportDialog, closeReportDialog] = useDialog(); const [post, setPost] = useState(); const [images, setImages] = useState(); const [type, setType] = useState<"reply" | "quote">(); @@ -54,6 +58,14 @@ export const PostThreadsContainer = (props: Props) => { [openImageDialog, setImages] ); + const onOpenReport = useCallback( + (post: AppBskyFeedDefs.PostView) => { + setPost(post); + openReportDialog(); + }, + [openReportDialog, setPost] + ); + const title = type === "reply" ? "Reply" : "Quote"; if (_.isEmpty(thread)) { @@ -69,19 +81,36 @@ export const PostThreadsContainer = (props: Props) => { {_.map(threadParent, (item) => ( - + ))} - + {_.map(threadReplies, (reply, key) => ( {_.map(reply, (item, index) => ( ))} @@ -90,6 +119,7 @@ export const PostThreadsContainer = (props: Props) => { ))} + ); }; diff --git a/src/containers/ProfileContainer.tsx b/src/containers/ProfileContainer.tsx index ddda47c..df9a004 100644 --- a/src/containers/ProfileContainer.tsx +++ b/src/containers/ProfileContainer.tsx @@ -11,7 +11,9 @@ import Profile from "@/components/Profile"; import Post from "@/components/Post"; import DialogPost from "@/components/DialogPost"; import DialogImage from "@/components/DialogImage"; +import DialogReport from "@/components/DialogReport"; import useDialog from "@/hooks/useDialog"; +import useMe from "@/hooks/useMe"; import { AppBskyFeedDefs, AppBskyEmbedImages } from "@atproto/api"; type Props = { @@ -19,10 +21,12 @@ type Props = { }; export const ProfileContainer = (props: Props) => { + const me = useMe(); const actor = useStore((state) => state.actor); const authorFeed = useStore((state) => state.authorFeed); const getProfile = useStore((state) => state.getProfile); const getAuthorFeed = useStore((state) => state.getAuthorFeed); + const [isOpenReport, openReportDialog, closeReportDialog] = useDialog(); const [isOpen, openPostDialog, closePostDialog] = useDialog(); const [isOpenImage, openImageDialog, closeImageDialog] = useDialog(); const [post, setPost] = useState(); @@ -55,6 +59,14 @@ export const ProfileContainer = (props: Props) => { [openImageDialog, setImages] ); + const onOpenReport = useCallback( + (post: AppBskyFeedDefs.PostView) => { + setPost(post); + openReportDialog(); + }, + [openReportDialog, setPost] + ); + const title = type === "reply" ? "Reply" : "Quote"; return ( @@ -66,9 +78,11 @@ export const ProfileContainer = (props: Props) => { {AppBskyFeedDefs.isPostView(item.reply?.root) && item.reply?.root && ( @@ -77,22 +91,32 @@ export const ProfileContainer = (props: Props) => { item.reply?.parent && item.reply?.parent.cid !== item.reply?.root.cid && ( )} - + ))} - + + ); }; diff --git a/src/containers/SearchContainer.tsx b/src/containers/SearchContainer.tsx new file mode 100644 index 0000000..e373a90 --- /dev/null +++ b/src/containers/SearchContainer.tsx @@ -0,0 +1,103 @@ +import _ from "lodash"; +import { useCallback, useState } from "react"; +import { useStore } from "@/stores"; +import Box from "@mui/material/Box"; +import Divider from "@mui/material/Divider"; +import CenterLayout from "@/templates/CenterLayout"; +import ScrollLayout from "@/templates/ScrollLayout"; +import DialogPost from "@/components/DialogPost"; +import DialogImage from "@/components/DialogImage"; +import DialogReport from "@/components/DialogReport"; +import NotFound from "@/components/NotFound"; +import Post from "@/components/Post"; +import Follow from "@/components/Follow"; +import useDialog from "@/hooks/useDialog"; +import useMe from "@/hooks/useMe"; +import { AppBskyFeedDefs, AppBskyEmbedImages } from "@atproto/api"; + +type Props = { + keyword: string; + type: "posts" | "users"; +}; + +export const SearchContainer = (props: Props) => { + const me = useMe(); + const searchedPosts = useStore((state) => state.searchedPosts); + const searchedActors = useStore((state) => state.searchedActors); + const searchPost = useStore((state) => state.searchPost); + const searchActor = useStore((state) => state.searchActor); + const searchedKeyword = useStore((state) => state.searchedKeyword); + const [isOpenPost, openPostDialog, closePostDialog] = useDialog(); + const [isOpenImage, openImageDialog, closeImageDialog] = useDialog(); + const [isOpenReport, openReportDialog, closeReportDialog] = useDialog(); + const [post, setPost] = useState(); + const [images, setImages] = useState(); + const [type, setType] = useState<"reply" | "quote">(); + + if (_.isUndefined(undefined)) { + // TODO suggest検索 + } + + if (props.keyword && searchedKeyword !== props.keyword) { + throw Promise.all([searchPost(props.keyword), searchActor(props.keyword)]); + } + + const onOpenPost = useCallback( + (post: AppBskyFeedDefs.PostView, type: "reply" | "quote") => { + setPost(post); + setType(type); + openPostDialog(); + }, + [openPostDialog, setPost, setType] + ); + + const onOpenImage = useCallback( + (images: AppBskyEmbedImages.ViewImage[]) => { + setImages(images); + openImageDialog(); + }, + [openImageDialog, setImages] + ); + + const onOpenReport = useCallback( + (post: AppBskyFeedDefs.PostView) => { + setPost(post); + openReportDialog(); + }, + [openReportDialog, setPost] + ); + + const title = type === "reply" ? "Reply" : "Quote"; + + if (_.isEmpty(searchedActors) || _.isEmpty(searchedPosts) || !props.keyword) { + return ( + + + + ); + } + + return ( + + {props.type === "posts" && + _.map(searchedPosts, (post, key) => ( + + + + + ))} + {props.type === "users" && + _.map(searchedActors, (actor, key) => ( + + + + + ))} + + + + + ); +}; + +export default SearchContainer; diff --git a/src/containers/SettingContainer.tsx b/src/containers/SettingContainer.tsx new file mode 100644 index 0000000..4f49ffa --- /dev/null +++ b/src/containers/SettingContainer.tsx @@ -0,0 +1,124 @@ +import _ from "lodash"; +import { useStore } from "@/stores"; +import { grey } from "@mui/material/colors"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import ListItemButton from "@mui/material/ListItemButton"; +import LogoutIcon from "@mui/icons-material/Logout"; +import AlternateEmailIcon from "@mui/icons-material/AlternateEmail"; +import ConfirmationNumberIcon from "@mui/icons-material/ConfirmationNumber"; +import TagRoundedIcon from "@mui/icons-material/TagRounded"; +import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; +// import ViewComfyRoundedIcon from "@mui/icons-material/ViewComfyRounded"; +import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded"; +import ProfileHeader from "@/components/ProfileHeader"; +import DialogHandle from "@/components/DialogHandle"; +import DialogInviteCodes from "@/components/DialogInviteCodes"; +import DialogPasswords from "@/components/DialogPasswords"; +import DialogContentFilter from "@/components/DialogContentFilter"; +import DialogFeed from "@/components/DialogFeed"; +import useMe from "@/hooks/useMe"; +import useAuthentication from "@/hooks/useAuthentication"; +import useDialog from "@/hooks/useDialog"; + +export const Settings = () => { + const me = useMe(); + const preferences = useStore((state) => state.preferences); + const savedFeeds = useStore((state) => state.savedFeeds); + const inviteCodes = useStore((state) => state.inviteCodes); + const appPasswords = useStore((state) => state.appPasswords); + const { onLogout } = useAuthentication(); + const getPreferences = useStore((state) => state.getPreferences); + const getInviteCodes = useStore((state) => state.getInviteCodes); + const listAppPasswords = useStore((state) => state.listAppPasswords); + const [isOpenHandle, openHandleDialog, closeHandleDialog] = useDialog(); + const [isOpenCode, openCodeDialog, closeCodeDialog] = useDialog(); + const [isOpenPassword, openPasswordDialog, closePasswordDialog] = useDialog(); + const [isOpenFilter, openFilterDialog, closeFilterDialog] = useDialog(); + const [isOpenFeed, openFeedDialog, closeFeedDialog] = useDialog(); + + if (_.isEmpty(preferences) || _.isUndefined(inviteCodes) || _.isUndefined(appPasswords)) { + throw Promise.all([getPreferences(), getInviteCodes(), listAppPasswords()]); + } + + const specialMenu = [ + { name: "inviteCode", label: "Invite Code", icon: , onClick: openCodeDialog }, + ]; + const menu = [ + { name: "appPassword", label: "Add Password", icon: , onClick: openPasswordDialog }, + { name: "savedFeed", label: "Saved Feed", icon: , onClick: openFeedDialog }, + // { name: "deck", label: "Home Customize", icon: , onClick: () => {} }, + { name: "changeHandle", label: "Change Handle", icon: , onClick: openHandleDialog }, + ]; + + const moderationMenu = [ + { name: "contentFilter", label: "Content Filter", icon: , onClick: openFilterDialog }, + ]; + + return ( + <> + + + + + + + + + {_.map(specialMenu, (item) => ( + + + {item.icon} + {item.label}} /> + + + ))} + + + + Advance Setting + + + {_.map(menu, (item) => ( + + + {item.icon} + {item.label}} /> + + + ))} + + + + Moderation + + + {_.map(moderationMenu, (item) => ( + + + {item.icon} + {item.label}} /> + + + ))} + + + + + + + + + ); +}; + +export default Settings; diff --git a/src/containers/TimelineContainer.tsx b/src/containers/TimelineContainer.tsx index e3d28f3..c7f8774 100644 --- a/src/containers/TimelineContainer.tsx +++ b/src/containers/TimelineContainer.tsx @@ -10,15 +10,22 @@ import ScrollLayout from "@/templates/ScrollLayout"; import Post from "@/components/Post"; import DialogPost from "@/components/DialogPost"; import DialogImage from "@/components/DialogImage"; +import DialogReport from "@/components/DialogReport"; +import UnreadPosts from "@/components/UnreadPosts"; import useDialog from "@/hooks/useDialog"; +import useMe from "@/hooks/useMe"; import { AppBskyFeedDefs, AppBskyEmbedImages } from "@atproto/api"; export const TimelineContainer = () => { + const me = useMe(); const timeline = useStore((state) => state.timeline); + const unreadTimeline = useStore((state) => state.unreadTimeline); const getTimeline = useStore((state) => state.getTimeline); const getInitialTimeline = useStore((state) => state.getInitialTimeline); + const drainTimeline = useStore((state) => state.drainTimeline); const [isOpenPost, openPostDialog, closePostDialog] = useDialog(); const [isOpenImage, openImageDialog, closeImageDialog] = useDialog(); + const [isOpenReport, openReportDialog, closeReportDialog] = useDialog(); const [post, setPost] = useState(); const [images, setImages] = useState(); const [type, setType] = useState<"reply" | "quote">(); @@ -48,43 +55,69 @@ export const TimelineContainer = () => { [openImageDialog, setImages] ); + const onOpenReport = useCallback( + (post: AppBskyFeedDefs.PostView) => { + setPost(post); + openReportDialog(); + }, + [openReportDialog, setPost] + ); + const title = type === "reply" ? "Reply" : "Quote"; + // TODO 投稿後にcidのpostがundefinedになることがある return ( - + + {_.size(unreadTimeline) ? ( + + + + ) : null} {_.map(timeline, (item) => ( - + {AppBskyFeedDefs.isPostView(item.reply?.root) && item.reply?.root && ( )} {AppBskyFeedDefs.isPostView(item.reply?.parent) && item.reply?.parent && - item.reply?.parent.cid !== item.reply?.root.cid && ( + item.reply?.parent?.cid !== item.reply?.root?.cid && ( )} - + ))} - + + ); }; diff --git a/src/hooks/useFeedGenerator.tsx b/src/hooks/useFeedGenerator.tsx new file mode 100644 index 0000000..075e9a1 --- /dev/null +++ b/src/hooks/useFeedGenerator.tsx @@ -0,0 +1,105 @@ +import _ from "lodash"; +import { useCallback } from "react"; +import { useStore } from "@/stores"; +import { AppBskyFeedDefs, AppBskyActorDefs } from "@atproto/api"; + +export const useFeedGenerator = () => { + const like = useStore((state) => state.like); + const deleteLike = useStore((state) => state.deleteLike); + const updatePreferences = useStore((state) => state.updatePreferences); + const updateFeedViewer = useStore((state) => state.updateFeedViewer); + + const updateViewer = useCallback( + (f: AppBskyFeedDefs.GeneratorView, resourceURI?: string) => { + const feed = { ...f }; + if (resourceURI) { + if (_.isNumber(feed.likeCount)) { + feed.likeCount += 1; + feed.viewer = { ...feed, like: resourceURI }; + } + } else { + if (_.isNumber(feed.likeCount)) { + feed.likeCount -= 1; + feed.viewer = _.omit(feed.viewer, "like"); + } + } + updateFeedViewer(feed); + }, + [updateFeedViewer] + ); + + const isSaved = useCallback((feed: AppBskyFeedDefs.GeneratorView, preferences: AppBskyActorDefs.Preferences) => { + const feedPref = _.find(preferences, (p) => AppBskyActorDefs.isSavedFeedsPref(p)); + return AppBskyActorDefs.isSavedFeedsPref(feedPref) && _.find(feedPref.saved, (uri) => feed.uri === uri); + }, []); + + const isPinned = useCallback((feed: AppBskyFeedDefs.GeneratorView, preferences: AppBskyActorDefs.Preferences) => { + const feedPref = _.find(preferences, (p) => AppBskyActorDefs.isSavedFeedsPref(p)); + return AppBskyActorDefs.isSavedFeedsPref(feedPref) && _.find(feedPref.pinned, (uri) => feed.uri === uri); + }, []); + + const onToggleSave = useCallback( + (feed: AppBskyFeedDefs.GeneratorView, preferences: AppBskyActorDefs.Preferences) => { + const update = _.map(preferences, (p) => { + if (AppBskyActorDefs.isSavedFeedsPref(p)) { + return _.find(p.saved, (uri) => feed.uri === uri) + ? { ...p, saved: _.reject(p.saved, (uri) => uri === feed.uri) } + : { ...p, saved: _.concat(p.saved, feed.uri) }; + } + return p; + }); + updatePreferences(update); + }, + [updatePreferences] + ); + + const onTogglePin = useCallback( + (feed: AppBskyFeedDefs.GeneratorView, preferences: AppBskyActorDefs.Preferences) => { + const update = _.map(preferences, (p) => { + if (AppBskyActorDefs.isSavedFeedsPref(p)) { + return _.find(p.pinned, (uri) => feed.uri === uri) + ? { ...p, pinned: _.reject(p.pinned, (uri) => uri === feed.uri) } + : { ...p, pinned: _.concat(p.pinned, feed.uri) }; + } + return p; + }); + updatePreferences(update); + }, + [updatePreferences] + ); + + const onLike = useCallback( + async (feed: AppBskyFeedDefs.GeneratorView) => { + const res = await like(feed); + updateViewer(feed, res?.uri); + }, + [like, updateViewer] + ); + + const onDeleteLike = useCallback( + async (feed: AppBskyFeedDefs.GeneratorView) => { + deleteLike(feed); + updateViewer(feed); + }, + [deleteLike, updateViewer] + ); + + const onToggleLike = useCallback( + async (feed: AppBskyFeedDefs.GeneratorView) => { + return feed.viewer?.like ? onDeleteLike(feed) : onLike(feed); + }, + [onDeleteLike, onLike] + ); + + const onShare = useCallback((feed: AppBskyFeedDefs.GeneratorView) => { + const url = _.chain(feed.uri) + .replace("at://", "https://bsky.app/profile/") + .replace("/app.bsky.feed.generator/", "/feed/") + .value(); + navigator.clipboard.writeText(url); + }, []); + + return { isPinned, isSaved, onToggleLike, onToggleSave, onTogglePin, onShare } as const; +}; + +export default useFeedGenerator; diff --git a/src/hooks/useHandle.tsx b/src/hooks/useHandle.tsx index 496a813..e64f5d3 100644 --- a/src/hooks/useHandle.tsx +++ b/src/hooks/useHandle.tsx @@ -25,7 +25,11 @@ export const useHandle = () => { updateHandle(`${handle}.bsky.social`); }, [handle, updateHandle]); - return { handle, onChangeHandle, onUpdateHandle }; + const onClearHandle = useCallback(() => { + setHandle(""); + }, [setHandle]); + + return { handle, onChangeHandle, onUpdateHandle, onClearHandle }; }; export default useHandle; diff --git a/src/hooks/useLocale.tsx b/src/hooks/useLocale.tsx new file mode 100644 index 0000000..4757b41 --- /dev/null +++ b/src/hooks/useLocale.tsx @@ -0,0 +1,24 @@ +import _ from "lodash"; +import { useCallback } from "react"; +import { formatDistanceToNowStrict, format } from "date-fns"; +import { ja } from "date-fns/locale"; + +export const useLocale = () => { + const fromNow = useCallback((indexedAt: string) => { + if (_.includes(navigator.languages, "ja")) { + return formatDistanceToNowStrict(Date.parse(indexedAt), { locale: ja }); + } + return formatDistanceToNowStrict(Date.parse(indexedAt)); + }, []); + + const locale = useCallback((indexedAt: string) => { + if (_.includes(navigator.languages, "ja")) { + return format(new Date(indexedAt), "yyyy年MM月dd日", { locale: ja }); + } + return format(new Date(indexedAt), "yyyy-MM-dd"); + }, []); + + return { fromNow, locale }; +}; + +export default useLocale; diff --git a/src/hooks/usePost.tsx b/src/hooks/usePost.tsx index 8c533ea..1272fdf 100644 --- a/src/hooks/usePost.tsx +++ b/src/hooks/usePost.tsx @@ -1,12 +1,13 @@ import _ from "lodash"; import { useCallback } from "react"; import { useStore } from "@/stores"; -import { useNavigate } from "react-router-dom"; -import { Record, BlobRequest } from "@/stores/feed"; +import { useNavigate, useLocation } from "react-router-dom"; +import { Record, BlobRequest } from "@/stores/timeline"; import { AppBskyFeedDefs } from "@atproto/api"; export const usePost = () => { const navigate = useNavigate(); + const location = useLocation(); const post = useStore((state) => state.post); const uploadBlob = useStore((state) => state.uploadBlob); const deletePost = useStore((state) => state.deletePost); @@ -18,6 +19,7 @@ export const usePost = () => { const updateAuthorFeedViewer = useStore((state) => state.updateAuthorFeedViewer); const updateNotificationViewer = useStore((state) => state.updateNotificationViewer); const updatePostThreadViewer = useStore((state) => state.updatePostThreadViewer); + const updateSearchViewer = useStore((state) => state.updateSearchViewer); const onUploadBlob = useCallback( (data: BlobRequest) => { @@ -34,7 +36,8 @@ export const usePost = () => { ); const updateViewer = useCallback( - (post: AppBskyFeedDefs.PostView, action: "like" | "repost", resourceURI?: string) => { + (p: AppBskyFeedDefs.PostView, action: "like" | "repost", resourceURI?: string) => { + const post = { ...p }; if (_.has(post.viewer, action)) { if (_.isNumber(post.likeCount) && action === "like") { post.likeCount -= 1; @@ -56,8 +59,9 @@ export const usePost = () => { updateAuthorFeedViewer(post); updateNotificationViewer(post); updatePostThreadViewer(post); + updateSearchViewer(post); }, - [updateTimelineViewer, updateAuthorFeedViewer, updateNotificationViewer, updatePostThreadViewer] + [updateTimelineViewer, updateAuthorFeedViewer, updateNotificationViewer, updatePostThreadViewer, updateSearchViewer] ); const onDeletePost = useCallback( @@ -110,10 +114,11 @@ export const usePost = () => { if (document.getSelection()?.toString()) return; const id = _.last(_.split(post.uri, "/")); const url = `/profile/${post.author.handle}/post/${id}`; - //TODO 同じページへのnavigateをスキップしないと履歴に積まれる - navigate(url); + if (location.pathname !== url) { + navigate(url); + } }, - [navigate] + [navigate, location] ); return { onUploadBlob, onPost, onDeletePost, onRepost, onDeleteRepost, onLike, onDeleteLike, onShare, onViewThread }; diff --git a/src/hooks/usePreference.tsx b/src/hooks/usePreference.tsx new file mode 100644 index 0000000..0c0099f --- /dev/null +++ b/src/hooks/usePreference.tsx @@ -0,0 +1,16 @@ +import _ from "lodash"; +import { useStore } from "@/stores"; + +export const usePreference = () => { + const preferences = useStore((state) => state.preferences); + const getPreferences = useStore((state) => state.getPreferences); + const savedFeeds = useStore((state) => state.savedFeeds); + + if (_.isEmpty(preferences)) { + throw getPreferences(); + } + + return { preferences, savedFeeds } as const; +}; + +export default usePreference; diff --git a/src/hooks/useRealtime.tsx b/src/hooks/useRealtime.tsx new file mode 100644 index 0000000..0c8cb67 --- /dev/null +++ b/src/hooks/useRealtime.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react"; +import { useStore } from "@/stores"; +import useNotification from "@/hooks/useNotification"; + +export const useRealtime = () => { + const INTERVAL = 10 * 1000; + const reloadTimeline = useStore((state) => state.reloadTimeline); + const unreadTimeline = useStore((state) => state.unreadTimeline); + const { unreadCount, countUnreadNotifications } = useNotification(); + + useEffect(() => { + const interval = setInterval(async () => { + countUnreadNotifications(); + reloadTimeline(); + }, INTERVAL); + return () => { + clearInterval(interval); + }; + }, [countUnreadNotifications, reloadTimeline, INTERVAL]); + + return { unreadCount, unreadTimeline } as const; +}; + +export default useRealtime; diff --git a/src/hooks/useSearch.tsx b/src/hooks/useSearch.tsx new file mode 100644 index 0000000..ef9c070 --- /dev/null +++ b/src/hooks/useSearch.tsx @@ -0,0 +1,36 @@ +import { useMemo, useState, useCallback } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; + +export const useSearch = () => { + const { search } = useLocation(); + const query = useMemo(() => new URLSearchParams(search), [search]); + const navigate = useNavigate(); + const location = useLocation(); + const [keyword, setKeyword] = useState(query.get("q") || ""); + const [isFocus, setFocus] = useState(false); + + const onChangeSearch = useCallback( + (e: React.ChangeEvent) => { + setKeyword(e.currentTarget.value); + }, + [setKeyword] + ); + + const onSearch = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + const path = location.pathname === "/" ? "/search" : location.pathname; + navigate(`${path}?q=${keyword}`); + } + }, + [keyword, navigate, location] + ); + + const onFocus = useCallback(() => { + setFocus(!isFocus); + }, [isFocus, setFocus]); + + return { keyword, isFocus, onFocus, onSearch, onChangeSearch } as const; +}; + +export default useSearch; diff --git a/src/hooks/useSocial.tsx b/src/hooks/useSocial.tsx index 1f73d18..f4e2bc8 100644 --- a/src/hooks/useSocial.tsx +++ b/src/hooks/useSocial.tsx @@ -12,6 +12,7 @@ export const useSocial = () => { const mute = useStore((state) => state.mute); const unmute = useStore((state) => state.unmute); const updateFollowViewer = useStore((state) => state.updateFollowViwer); + const updateSuggestionViewer = useStore((state) => state.updateSuggestionViewer); // TODO フォロー画面の更新 const onFollow = useCallback( @@ -19,8 +20,9 @@ export const useSocial = () => { await follow(actor.did); await getProfile(actor.handle); // updateFollowViewer(actor, "followers"); + updateSuggestionViewer(actor.did); }, - [follow, getProfile, updateFollowViewer] + [follow, getProfile, updateFollowViewer, updateSuggestionViewer] ); const onUnFollow = useCallback( diff --git a/src/hooks/useSuggestion.tsx b/src/hooks/useSuggestion.tsx new file mode 100644 index 0000000..f0b01ca --- /dev/null +++ b/src/hooks/useSuggestion.tsx @@ -0,0 +1,14 @@ +import { useStore } from "@/stores"; + +export const useSuggestion = () => { + const suggestions = useStore((state) => state.suggestions); + const getSuggestions = useStore((state) => state.getSuggestions); + + if (!suggestions) { + throw getSuggestions(); + } + + return suggestions; +}; + +export default useSuggestion; diff --git a/src/pages/Feeds.tsx b/src/pages/Feeds.tsx index 5965aee..54750b5 100644 --- a/src/pages/Feeds.tsx +++ b/src/pages/Feeds.tsx @@ -11,7 +11,7 @@ export const Feeds = () => { return ( - + }> diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index cbdd44b..dd957c0 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,14 +1,21 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import Container from "@mui/material/Container"; +import { grey } from "@mui/material/colors"; +import Link from "@mui/material/Link"; import Typography from "@mui/material/Typography"; import Card from "@mui/material/Card"; +import CardHeader from "@mui/material/CardHeader"; import CardActions from "@mui/material/CardActions"; import CardContent from "@mui/material/CardContent"; import Button from "@mui/material/Button"; -import Divider from "@mui/material/Divider"; +import Box from "@mui/material/Box"; import TextField from "@mui/material/TextField"; +import InputAdornment from "@mui/material/InputAdornment"; import Stack from "@mui/material/Stack"; +import PersonRoundedIcon from "@mui/icons-material/PersonRounded"; +import KeyRoundedIcon from "@mui/icons-material/KeyRounded"; +import WaterDropOutlinedIcon from "@mui/icons-material/WaterDropOutlined"; +import CenterLayout from "@/templates/CenterLayout"; import useAuthentication from "@/hooks/useAuthentication"; export const Login = () => { @@ -22,34 +29,72 @@ export const Login = () => { }, [session, navigate]); return ( - - + + + RAIN} + avatar={} + /> - - SKYLINE - - - - - - + + + + Handle or Email + + + + + ), + }} + /> + + + + Password + + input:-webkit-autofill)": { + backgroundColor: "none", + }, + }} + fullWidth + variant="outlined" + type="password" + autoComplete="current-password" + focused + onChange={onChange("password")} + InputProps={{ + sx: { borderRadius: 2 }, + startAdornment: ( + + + + ), + }} + /> + + + We highly recommned you to use{" "} + app password. + - - - - + ); }; diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 3b01f0e..f46cdc0 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -1,19 +1,26 @@ import { Suspense } from "react"; import Layout from "@/templates/Layout"; -import FeedsContainer from "@/containers/FeedsContainer"; +import SearchContainer from "@/containers/SearchContainer"; import HistoryLayout from "@/templates/HistoryLayout"; +import TabLayout from "@/templates/TabLayout"; import TimelineTemplate from "@/templates/TimelineTemplate"; import useQuery from "@/hooks/useQuery"; export const Search = () => { const query = useQuery(); + const keyword = query.get("q") || ""; return ( - }> - - + + }> + + + }> + + + ); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index beae076..db52d26 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,38 +1,16 @@ +import { Suspense } from "react"; +import SettingTemplate from "@/templates/SettingTemplate"; import Layout from "@/templates/Layout"; import HistoryLayout from "@/templates/HistoryLayout"; -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import ProfileHeader from "@/components/ProfileHeader"; -import useMe from "@/hooks/useMe"; -import useAuthentication from "@/hooks/useAuthentication"; -import Button from "@mui/material/Button"; -import LogoutIcon from "@mui/icons-material/Logout"; -import AlternateEmailIcon from "@mui/icons-material/AlternateEmail"; -import DialogHandle from "@/components/DialogHandle"; -import useDialog from "@/hooks/useDialog"; +import SettingsContainer from "@/containers/SettingContainer"; export const Settings = () => { - const me = useMe(); - const { onLogout } = useAuthentication(); - const [isOpen, openHandleDialog, closeHandleDialog] = useDialog(); - return ( - - - - - - - - - - + }> + + ); diff --git a/src/stores/actor.tsx b/src/stores/actor.tsx index 40e1de3..4b3d2c7 100644 --- a/src/stores/actor.tsx +++ b/src/stores/actor.tsx @@ -10,14 +10,14 @@ export interface ActorSlice { actor?: AppBskyActorDefs.ProfileViewDetailed; authorFeed: AppBskyFeedDefs.FeedViewPost[]; authorCursor: string; - preferences: AppBskyActorDefs.Preferences; + suggestions?: AppBskyActorDefs.ProfileViewDetailed[]; getMe: () => Promise; getProfile: (actor: string) => Promise; getAuthorFeed: (actor: string, isReset: boolean) => Promise; - getPreferences: () => Promise; - updatePreferences: (preferences: AppBskyActorDefs.Preferences) => Promise; + getSuggestions: () => Promise; updateProfile: (record: AppBskyActorProfile.Record) => Promise; updateAuthorFeedViewer: (post: AppBskyFeedDefs.PostView) => void; + updateSuggestionViewer: (actor: string) => void; } export const createActorSlice: StateCreator = ( @@ -28,7 +28,7 @@ export const createActorSlice: StateCreator { try { const session = get().session; @@ -60,20 +60,12 @@ export const createActorSlice: StateCreator { + getSuggestions: async () => { try { - const res = await agent.api.app.bsky.actor.getPreferences(); - set({ preferences: res.data.preferences }); + const res = await agent.getSuggestions(); + set({ suggestions: res.data.actors }); } catch (e) { - get().createFailedMessage({ status: "error", description: "failed fetch timeline" }, e); - } - }, - updatePreferences: async (preferences: AppBskyActorDefs.Preferences) => { - try { - await agent.api.app.bsky.actor.putPreferences({ preferences }); - set({ preferences: preferences }); - } catch (e) { - get().createFailedMessage({ status: "error", description: "failed fetch timeline" }, e); + get().createFailedMessage({ status: "error", description: "failed fetch suggestions" }, e); } }, updateProfile: async (record: AppBskyActorProfile.Record) => { @@ -106,4 +98,8 @@ export const createActorSlice: StateCreator { + const suggestions = _.reject(get().suggestions, (suggest) => suggest.did === actor); + set({ suggestions }); + }, }); diff --git a/src/stores/feed_generator.tsx b/src/stores/feed_generator.tsx index 224db21..52de31b 100644 --- a/src/stores/feed_generator.tsx +++ b/src/stores/feed_generator.tsx @@ -16,6 +16,7 @@ export interface FeedGeneratorSlice { getFeedGenerator: (feed: string) => Promise; getFeedBrief: (feed: string) => Promise; getFeed: (feed: string, isReset: boolean) => Promise; + updateFeedViewer: (feedGenerator: AppBskyFeedDefs.GeneratorView) => void; } export const createFeedGeneratorSlice: StateCreator = ( @@ -73,4 +74,7 @@ export const createFeedGeneratorSlice: StateCreator { + set({ feedGenerator }); + }, }); diff --git a/src/stores/index.tsx b/src/stores/index.tsx index ad45d24..53aed21 100644 --- a/src/stores/index.tsx +++ b/src/stores/index.tsx @@ -8,6 +8,9 @@ import { NotificationSlice, createNotificationSlice } from "@/stores/notificatio import { SocialGraphSlice, createSocialGraphSlice } from "@/stores/social_graph"; import { IdentitySlice, createIdentitySlice } from "@/stores/identity"; import { FeedGeneratorSlice, createFeedGeneratorSlice } from "@/stores/feed_generator"; +import { SearchSlice, createSearchSlice } from "@/stores/search"; +import { ModerationSlice, createModerationSlice } from "@/stores/moderation"; +import { PreferenceSlice, createPreferenceSlice } from "@/stores/preference"; import { LayoutSlice, createLayoutSlice } from "@/stores/layout"; type StoreSlice = ActorSlice & @@ -18,7 +21,10 @@ type StoreSlice = ActorSlice & PostThreadSlice & IdentitySlice & FeedGeneratorSlice & + SearchSlice & MessageSlice & + PreferenceSlice & + ModerationSlice & LayoutSlice; export const useStore = create()((...a) => ({ @@ -31,5 +37,8 @@ export const useStore = create()((...a) => ({ ...createSocialGraphSlice(...a), ...createIdentitySlice(...a), ...createFeedGeneratorSlice(...a), + ...createSearchSlice(...a), + ...createPreferenceSlice(...a), + ...createModerationSlice(...a), ...createLayoutSlice(...a), })); diff --git a/src/stores/moderation.tsx b/src/stores/moderation.tsx new file mode 100644 index 0000000..cc92218 --- /dev/null +++ b/src/stores/moderation.tsx @@ -0,0 +1,48 @@ +import { StateCreator } from "zustand"; +import { MessageSlice } from "@/stores/message"; +import { ActorSlice } from "@/stores/actor"; +import agent from "@/agent"; + +export interface ModerationSlice { + reportActor: (reason: string, reasonType: string, did: string) => Promise; + reportPost: (reason: string, reasonType: string, cid: string, uri: string) => Promise; +} + +export const createModerationSlice: StateCreator< + ModerationSlice & MessageSlice & ActorSlice, + [], + [], + ModerationSlice +> = (_, get) => ({ + reportActor: async (reason: string, reasonType: string, did: string) => { + try { + await agent.api.com.atproto.moderation.createReport({ + reason, + reasonType, + subject: { + name: "report", + $type: "com.atproto.admin.defs#repoRef", + did, + }, + }); + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed to repost user" }, e); + } + }, + reportPost: async (reason: string, reasonType: string, cid: string, uri: string) => { + try { + await agent.api.com.atproto.moderation.createReport({ + reason, + reasonType, + subject: { + name: "report", + $type: "com.atproto.repo.strongRef", + uri, + cid, + }, + }); + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed to repost post" }, e); + } + }, +}); diff --git a/src/stores/preference.tsx b/src/stores/preference.tsx new file mode 100644 index 0000000..d78fbc6 --- /dev/null +++ b/src/stores/preference.tsx @@ -0,0 +1,91 @@ +import _ from "lodash"; +import { StateCreator } from "zustand"; +import { MessageSlice } from "@/stores/message"; +import { SessionSlice } from "@/stores/session"; +import { AppBskyFeedDefs, AppBskyActorDefs } from "@atproto/api"; +import agent from "@/agent"; + +export interface PreferenceSlice { + preferences: AppBskyActorDefs.Preferences; + inviteCodes?: { used: boolean; code: string }[] | null; + appPasswords?: { name: string; createdAt: string }[]; + createdHash: string; + savedFeeds: AppBskyFeedDefs.GeneratorView[]; + getPreferences: () => Promise; + updatePreferences: (preferences: AppBskyActorDefs.Preferences) => Promise; + getInviteCodes: () => Promise; + listAppPasswords: () => Promise; + createAppPassword: (name: string) => Promise; + deleteAppPassword: (name: string) => Promise; +} + +export const createPreferenceSlice: StateCreator< + PreferenceSlice & MessageSlice & SessionSlice, + [], + [], + PreferenceSlice +> = (set, get) => ({ + preferences: [], + inviteCodes: undefined, + appPasswords: undefined, + savedFeeds: [], + createdHash: "", + getPreferences: async () => { + try { + const res = await agent.api.app.bsky.actor.getPreferences(); + const feedPref = _.find(res.data.preferences, (p) => AppBskyActorDefs.isSavedFeedsPref(p)); + const saved = (AppBskyActorDefs.isSavedFeedsPref(feedPref) && feedPref?.saved) || []; + const feedResponse = await agent.api.app.bsky.feed.getFeedGenerators({ feeds: saved }); + set({ preferences: res.data.preferences, savedFeeds: feedResponse.data.feeds }); + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed fetch timeline" }, e); + } + }, + updatePreferences: async (preferences: AppBskyActorDefs.Preferences) => { + try { + await agent.api.app.bsky.actor.putPreferences({ preferences }); + set({ preferences }); + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed fetch timeline" }, e); + } + }, + getInviteCodes: async () => { + try { + const res = await agent.api.com.atproto.server.getAccountInviteCodes(); + const inviteCodes = _.chain(res.data.codes) + .map((code) => ({ used: !_.isEmpty(code.uses), code: code.code })) + .sortBy("used") + .value(); + set({ inviteCodes }); + } catch (e) { + set({ inviteCodes: null }); + } + }, + listAppPasswords: async () => { + try { + const res = await agent.api.com.atproto.server.listAppPasswords(); + const appPasswords = _.map(res.data.passwords, (item) => ({ name: item.name, createdAt: item.createdAt })); + set({ appPasswords }); + } catch (e) { + set({ appPasswords: [] }); + get().createFailedMessage({ status: "error", description: "failed fetch app passwords" }, e); + } + }, + createAppPassword: async (name: string) => { + try { + const res = await agent.api.com.atproto.server.createAppPassword({ name }); + await get().listAppPasswords(); + return res.data.password; + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed to create app passwords" }, e); + } + }, + deleteAppPassword: async (name: string) => { + try { + await agent.api.com.atproto.server.revokeAppPassword({ name }); + await get().listAppPasswords(); + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed to revoke app passwords" }, e); + } + }, +}); diff --git a/src/stores/search.tsx b/src/stores/search.tsx new file mode 100644 index 0000000..0d892d8 --- /dev/null +++ b/src/stores/search.tsx @@ -0,0 +1,66 @@ +import _ from "lodash"; +import { StateCreator } from "zustand"; +import { MessageSlice } from "@/stores/message"; +import { AppBskyActorDefs, AppBskyFeedDefs } from "@atproto/api"; +import agent from "@/agent"; + +const SEARCH_URI = "https://search.bsky.social/search"; + +export interface SearchSlice { + searchedKeyword: string; + searchedActors: AppBskyActorDefs.ProfileView[]; + searchedPosts: AppBskyFeedDefs.PostView[]; + searchPost: (q: string) => Promise; + searchActor: (q: string) => Promise; + updateSearchViewer: (post: AppBskyFeedDefs.PostView) => void; +} + +export const createSearchSlice: StateCreator = (set, get) => ({ + searchedKeyword: "", + searchedActors: [], + searchedPosts: [], + searchPost: async (q: string) => { + try { + if (!q) return; + const searched = await fetch(`${SEARCH_URI}/posts?q=${q}`); + const result = await searched.json(); + if (result.error) throw Error(); + const uris = _.chain(result) + .map((item) => `at://${item.user.did}/${item.tid}`) + .take(25) + .value(); + if (_.isEmpty(uris)) { + set({ searchedKeyword: q }); + return; + } + const res = await agent.getPosts({ uris }); + set({ searchedPosts: res.data.posts, searchedKeyword: q }); + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed to search actor URIs" }, e); + } + }, + searchActor: async (q: string) => { + try { + if (!q) return; + const searched = await fetch(`${SEARCH_URI}/profiles?q=${q}`); + const result = await searched.json(); + if (result.error) throw Error(); + const actors = _.chain(result) + .map((item) => item.did) + .take(25) + .value(); + if (_.isEmpty(actors)) { + set({ searchedKeyword: q }); + return; + } + const res = await agent.getProfiles({ actors }); + set({ searchedActors: res.data.profiles, searchedKeyword: q }); + } catch (e) { + get().createFailedMessage({ status: "error", description: "failed to search post URIs" }, e); + } + }, + updateSearchViewer: (post: AppBskyFeedDefs.PostView) => { + const searchedPosts = _.map(get().searchedPosts, (subject) => (subject.uri === post.uri ? post : subject)); + set({ searchedPosts }); + }, +}); diff --git a/src/stores/session.tsx b/src/stores/session.tsx index 576bf8d..c314be1 100644 --- a/src/stores/session.tsx +++ b/src/stores/session.tsx @@ -19,11 +19,11 @@ export const createSessionSlice: StateCreator { try { const res = await agent.login({ identifier, password }); - localStorage.setItem("X-SKYLINE-REFRESHJWT", res.data.refreshJwt); - localStorage.setItem("X-SKYLINE-ACCESSJWT", res.data.accessJwt); - localStorage.setItem("X-SKYLINE-DID", res.data.did); - localStorage.setItem("X-SKYLINE-EMAIL", res.data.email || ""); - localStorage.setItem("X-SKYLINE-HANDLE", res.data.handle); + localStorage.setItem("X-RAIN-REFRESHJWT", res.data.refreshJwt); + localStorage.setItem("X-RAIN-ACCESSJWT", res.data.accessJwt); + localStorage.setItem("X-RAIN-DID", res.data.did); + localStorage.setItem("X-RAIN-EMAIL", res.data.email || ""); + localStorage.setItem("X-RAIN-HANDLE", res.data.handle); set({ session: res.data }); } catch (e) { get().createMessage({ status: "error", description: "invalid identifier" }); @@ -31,11 +31,11 @@ export const createSessionSlice: StateCreator { try { - localStorage.removeItem("X-SKYLINE-REFRESHJWT"); - localStorage.removeItem("X-SKYLINE-ACCESSJWT"); - localStorage.removeItem("X-SKYLINE-DID"); - localStorage.removeItem("X-SKYLINE-EMAIL"); - localStorage.removeItem("X-SKYLINE-HANDLE"); + localStorage.removeItem("X-RAIN-REFRESHJWT"); + localStorage.removeItem("X-RAIN-ACCESSJWT"); + localStorage.removeItem("X-RAIN-DID"); + localStorage.removeItem("X-RAIN-EMAIL"); + localStorage.removeItem("X-RAIN-HANDLE"); set({ session: undefined }); } catch (e) { get().createMessage({ status: "error", description: "logout error" }); @@ -44,22 +44,22 @@ export const createSessionSlice: StateCreator { try { const res = await agent.resumeSession({ - refreshJwt: localStorage.getItem("X-SKYLINE-REFRESHJWT") || "", - accessJwt: localStorage.getItem("X-SKYLINE-ACCESSJWT") || "", - did: localStorage.getItem("X-SKYLINE-DID") || "", - email: localStorage.getItem("X-SKYLINE-EMAIL") || "", - handle: localStorage.getItem("X-SKYLINE-HANDLE") || "", + refreshJwt: localStorage.getItem("X-RAIN-REFRESHJWT") || "", + accessJwt: localStorage.getItem("X-RAIN-ACCESSJWT") || "", + did: localStorage.getItem("X-RAIN-DID") || "", + email: localStorage.getItem("X-RAIN-EMAIL") || "", + handle: localStorage.getItem("X-RAIN-HANDLE") || "", }); const session = { - refreshJwt: localStorage.getItem("X-SKYLINE-REFRESHJWT") || "", - accessJwt: localStorage.getItem("X-SKYLINE-ACCESSJWT") || "", + refreshJwt: localStorage.getItem("X-RAIN-REFRESHJWT") || "", + accessJwt: localStorage.getItem("X-RAIN-ACCESSJWT") || "", did: res.data.did, handle: res.data.handle, email: res.data.email, }; set({ session }); } catch (e) { - get().createMessage({ status: "error", description: "token expired" }); + get().createMessage({ status: "info", description: "token expired" }); set({ session: null }); } }, diff --git a/src/stores/timeline.tsx b/src/stores/timeline.tsx index 5c55650..cf8f1eb 100644 --- a/src/stores/timeline.tsx +++ b/src/stores/timeline.tsx @@ -11,17 +11,22 @@ export type BlobResponse = ComAtprotoRepoUploadBlob.OutputSchema; export interface TimelineSlice { timeline: AppBskyFeedDefs.FeedViewPost[]; + unreadTimeline: AppBskyFeedDefs.FeedViewPost[]; timelineCursor: string; filterFeed: (feed: AppBskyFeedDefs.FeedViewPost[]) => AppBskyFeedDefs.FeedViewPost[]; getTimeline: () => Promise; getInitialTimeline: () => Promise; + reloadTimeline: () => Promise; + drainTimeline: () => void; uploadBlob: (data: BlobRequest) => Promise; post: (record: Record) => Promise; deletePost: (record: AppBskyFeedDefs.PostView) => Promise; repost: (record: AppBskyFeedDefs.PostView) => Promise; deleteRepost: (record: AppBskyFeedDefs.PostView) => Promise; - like: (record: AppBskyFeedDefs.PostView) => Promise; - deleteLike: (record: AppBskyFeedDefs.PostView) => Promise; + like: ( + record: AppBskyFeedDefs.PostView | AppBskyFeedDefs.GeneratorView + ) => Promise; + deleteLike: (record: AppBskyFeedDefs.PostView | AppBskyFeedDefs.GeneratorView) => Promise; updateTimelineViewer: (post: AppBskyFeedDefs.PostView) => void; } @@ -32,10 +37,10 @@ export const createTimelineSlice: StateCreator { try { - // TODO リアルタイムで新規投稿をチェックしたい const res = await agent.getTimeline({ cursor: get().timelineCursor, limit: 100 }); if (!res.data.cursor) return true; const filteredFeed = get().filterFeed(res.data.feed); @@ -63,6 +68,7 @@ export const createTimelineSlice: StateCreator { + const res = await agent.getTimeline(); + if (!res.data.cursor) return; + const filteredFeed = get().filterFeed(res.data.feed); + const first = _.first(get().timeline); + if (!first) return; + const index = _.findIndex(filteredFeed, (f) => f.post.uri === first.post.uri); + if (index === 0) return; + set({ unreadTimeline: _.take(filteredFeed, index) }); + return; + }, + drainTimeline: () => { + set({ timeline: _.concat(get().unreadTimeline, get().timeline), unreadTimeline: [] }); + }, post: async (record: Record) => { try { const postResponse = await agent.post(record); @@ -120,16 +140,16 @@ export const createTimelineSlice: StateCreator { + like: async (subject: AppBskyFeedDefs.PostView | AppBskyFeedDefs.GeneratorView) => { try { - return await agent.like(post.uri, post.cid); + return await agent.like(subject.uri, subject.cid); } catch (e) { get().createFailedMessage({ status: "error", description: "failed to like" }, e); } }, - deleteLike: async (post: AppBskyFeedDefs.PostView) => { + deleteLike: async (subject: AppBskyFeedDefs.PostView | AppBskyFeedDefs.GeneratorView) => { try { - await agent.deleteLike(post.viewer?.like || ""); + await agent.deleteLike(subject.viewer?.like || ""); } catch (e) { get().createFailedMessage({ status: "error", description: "failed to like" }, e); } diff --git a/src/templates/CenterLayout.tsx b/src/templates/CenterLayout.tsx index b73a0c9..cb2758c 100644 --- a/src/templates/CenterLayout.tsx +++ b/src/templates/CenterLayout.tsx @@ -1,5 +1,4 @@ import { ReactNode } from "react"; -import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; type Props = { @@ -8,16 +7,9 @@ type Props = { export const CenterLayout = (props: Props) => { return ( - - {props.children} - + + {props.children} + ); }; diff --git a/src/templates/HistoryLayout.tsx b/src/templates/HistoryLayout.tsx index d1ecf45..d1f3b7a 100644 --- a/src/templates/HistoryLayout.tsx +++ b/src/templates/HistoryLayout.tsx @@ -1,43 +1,18 @@ -import { ReactNode, useCallback, useState } from "react"; +import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import InputAdornment from "@mui/material/InputAdornment"; import IconButton from "@mui/material/IconButton"; -import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; import ArrowBackIosNewRoundedIcon from "@mui/icons-material/ArrowBackIosNewRounded"; +import Search from "@/components/Search"; type Props = { children: ReactNode; search?: boolean; - keyword?: string; }; // TODO コンテンツの上じゃなくて横のほうがいいかもしれない export const HistoryLayout = (props: Props) => { const navigate = useNavigate(); - const [keyword, setKeyword] = useState(props.keyword || ""); - const [isFocus, setFocus] = useState(false); - - const onChangeSearch = useCallback( - (e: React.ChangeEvent) => { - setKeyword(e.currentTarget.value); - }, - [setKeyword] - ); - - const onSearch = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - navigate(`/search?q=${keyword}`); - } - }, - [keyword, navigate] - ); - - const onFocus = useCallback(() => { - setFocus(!isFocus); - }, [isFocus, setFocus]); return ( <> @@ -45,27 +20,7 @@ export const HistoryLayout = (props: Props) => { navigate(-1)}> - {props.search && ( - - - - ), - }} - /> - )} + {props.search && } {props.children} diff --git a/src/templates/Layout.tsx b/src/templates/Layout.tsx index 8e36229..67b3c0a 100644 --- a/src/templates/Layout.tsx +++ b/src/templates/Layout.tsx @@ -1,35 +1,73 @@ import { ReactNode, Suspense } from "react"; import Grid from "@mui/material/Unstable_Grid2"; -import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; import Container from "@mui/material/Container"; import SideMenu from "@/components/SideMenu"; +import SideBar from "@/components/SideBar"; import MenuTemplate from "@/templates/MenuTemplate"; -import Paper from "@mui/material/Paper"; +import SideBarTemplate from "@/templates/SideBarTemplate"; import Message from "@/components/Message"; +import NotificationsContainer from "@/containers/NotificationsContainer"; +import FeedsContainer from "@/containers/FeedsContainer"; +import useQuery from "@/hooks/useQuery"; type Props = { children: ReactNode; }; +/* +export const Layout = (props: Props) => { + const query = useQuery(); + const keyword = query.get("q") || ""; + + return ( + + + + }> + + + + + + {props.children} + + + + + + + + + + + + + + + + ); +}; +*/ + export const Layout = (props: Props) => { return ( - + }> - - - + - + {props.children} - + + + + }> + + diff --git a/src/templates/MenuTemplate.tsx b/src/templates/MenuTemplate.tsx index 3ce6a58..a6f620f 100644 --- a/src/templates/MenuTemplate.tsx +++ b/src/templates/MenuTemplate.tsx @@ -5,12 +5,12 @@ import Skeleton from "@mui/material/Skeleton"; export const MenuTemplate = () => { return ( - + - - + + diff --git a/src/templates/ProfileTemplate.tsx b/src/templates/ProfileTemplate.tsx index cfa1a49..1f8a6d1 100644 --- a/src/templates/ProfileTemplate.tsx +++ b/src/templates/ProfileTemplate.tsx @@ -6,7 +6,7 @@ import TimelineTemplate from "@/templates/TimelineTemplate"; export const ProfileTemplate = () => { return ( - <> + @@ -24,7 +24,7 @@ export const ProfileTemplate = () => { - + ); }; diff --git a/src/templates/ScrollLayout.tsx b/src/templates/ScrollLayout.tsx index 1f9f797..7641022 100644 --- a/src/templates/ScrollLayout.tsx +++ b/src/templates/ScrollLayout.tsx @@ -5,11 +5,16 @@ import { useLocation } from "react-router-dom"; import Fade from "@mui/material/Fade"; import Fab from "@mui/material/Fab"; import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; import NavigationIcon from "@mui/icons-material/Navigation"; +import UnreadPosts from "@/components/UnreadPosts"; +import { AppBskyFeedDefs } from "@atproto/api"; type Props = { children: ReactNode; onScrollLimit?: () => void; + unread?: AppBskyFeedDefs.FeedViewPost[]; }; const SHOW_SCROLL_THREASHOLD = 3000; @@ -18,6 +23,7 @@ export const ScrollLayout = (props: Props) => { const { pathname } = useLocation(); const ref = useRef(null); const [hasScroll, setHasScroll] = useState(false); + const drainTimeline = useStore((state) => state.drainTimeline); const getScrollTop = useStore((state) => state.getScrollTop); const updateScrollTop = useStore((state) => state.updateScrollTop); @@ -51,30 +57,43 @@ export const ScrollLayout = (props: Props) => { ); const scrollTop = useCallback(() => { + setTimeout(drainTimeline, 100); ref?.current?.scrollTo({ top: 0, behavior: "smooth", }); - }, []); + }, [drainTimeline]); return ( - + - - - Top + + {_.size(props.unread) ? ( + + ) : ( + + + TOP + + )} {props.children} - + ); }; diff --git a/src/templates/SettingTemplate.tsx b/src/templates/SettingTemplate.tsx new file mode 100644 index 0000000..f30ead1 --- /dev/null +++ b/src/templates/SettingTemplate.tsx @@ -0,0 +1,25 @@ +import _ from "lodash"; +import Stack from "@mui/material/Stack"; +import Skeleton from "@mui/material/Skeleton"; + +export const SettingTemplate = () => { + return ( + + + + + + + + + {_.times(5, (item) => ( + + + + + ))} + + ); +}; + +export default SettingTemplate; diff --git a/src/templates/SideBarTemplate.tsx b/src/templates/SideBarTemplate.tsx new file mode 100644 index 0000000..6723ed4 --- /dev/null +++ b/src/templates/SideBarTemplate.tsx @@ -0,0 +1,40 @@ +import _ from "lodash"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Skeleton from "@mui/material/Skeleton"; + +export const MenuTemplate = () => { + return ( + + + + + {_.times(5, (key) => ( + + + + + + + + ))} + + + + + {_.times(4, (key) => ( + + + + + + + + ))} + + + + ); +}; + +export default MenuTemplate; diff --git a/src/templates/SlideInLayout.tsx b/src/templates/SlideInLayout.tsx deleted file mode 100644 index 15fd657..0000000 --- a/src/templates/SlideInLayout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useRef, ReactNode } from "react"; -import Slide from "@mui/material/Slide"; -import Box from "@mui/material/Box"; - -type Props = { - children: ReactNode; -}; - -export const SlideInLayout = (props: Props) => { - const containerRef = useRef(null); - return ( - - - {props.children} - - - ); -}; - -export default SlideInLayout; diff --git a/src/templates/TabLayout.tsx b/src/templates/TabLayout.tsx index 8433c51..adb1833 100644 --- a/src/templates/TabLayout.tsx +++ b/src/templates/TabLayout.tsx @@ -13,6 +13,7 @@ type Props = { }; export const TabLayout = (props: Props) => { + // TODO Toolbarを使ったら簡単かも const [tab, onChangeTab] = useTabs(); return ( @@ -24,8 +25,8 @@ export const TabLayout = (props: Props) => { {_.map(props.children, (component, key) => ( - - {component} + + {component} ))} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..59fd940 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + {"source": "/(.*)", "destination": "/"} + ] +}