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._**
+
+
+
+# 🖥 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
+
+
+
+ }
+ >
+
+
+ ))}
+
+
+
+
+
+ DONE
+
+
+
+ );
+};
+
+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) ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ DONE
+
+
+
+ );
+};
+
+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) => {
- Cancel
+ Cancel
Save
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}
+
+ }
+ />
+ )}
+
+ ))}
+
+
+
+
+
+ DONE
+
+
+
+ );
+};
+
+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.
+
+
+
+
+ Add
+
+
+ {created && (
+
+
+
+ {created}
+
+
+ )}
+
+
+ {_.map(props.passwords, (password) => (
+
+
+
+ }
+ >
+
+
+ ))}
+
+
+
+
+
+ DONE
+
+
+
+ );
+};
+
+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} />
+ ))}
+
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+ Send Report
+
+
+
+ );
+};
+
+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}
+
+
+
+ ))}
+
+ }
+ onClick={openPostDialog}
+ >
+ New Post
+
+
- ))}
-
- }
- onClick={openPostDialog}
- >
- New Post
-
-
-
-
+
+
);
};
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 (
+
+
+
+ {_.map(authors, (author, key) => (
+
+ ))}
+
+ about {_.size(props.unread)} new posts
+
+
+ );
+};
+
+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 (
+ <>
+
+
+
+
+ } onClick={onLogout}>
+ Logout
+
+
+
+
+ {_.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.
+
-
-
-
+
+
LOGIN
-
+
);
};
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 (
-
-
-
- } onClick={onLogout}>
- Logout
-
-
-
- } onClick={openHandleDialog}>
- Change Handle
-
-
-
-
+ }>
+
+
);
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": "/"}
+ ]
+}