diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/Activityfeed/ActivityEntryKnockout.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/Activityfeed/ActivityEntryKnockout.tsx index 4a89a84076..5842678327 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/Activityfeed/ActivityEntryKnockout.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/Activityfeed/ActivityEntryKnockout.tsx @@ -4,7 +4,9 @@ import { useChangedFieldNames } from '@/Components/useChangedFieldNames'; import { ActivityEntryContract } from '@/DataContracts/ActivityEntry/ActivityEntryContract'; import { EntryContract } from '@/DataContracts/EntryContract'; import { EntryType } from '@/Models/EntryType'; +import { useMutedUsers } from '@/MutedUsersContext'; import { EntryUrlMapper } from '@/Shared/EntryUrlMapper'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -90,110 +92,117 @@ interface ActivityEntryKnockoutProps { showDetails?: boolean; } -export const ActivityEntryKnockout = ({ - entry, - showDetails = false, -}: ActivityEntryKnockoutProps): React.ReactElement => { - const { t } = useTranslation(['HelperRes', 'ViewRes']); - - const activityFeedEventName = useActivityFeedEventName(); - const changedFieldNames = useChangedFieldNames(); - const entryTypeName = useEntryTypeName(); - - return ( -
- {entry.author ? ( - - - - ) : ( - {t('HelperRes:ActivityFeedHelper.Someone')} - )}{' '} - {activityFeedEventName(entry)} - {showDetails && entry.archivedVersion && ( - <> - {' '} +export const ActivityEntryKnockout = observer( + ({ + entry, + showDetails = false, + }: ActivityEntryKnockoutProps): React.ReactElement => { + const { t } = useTranslation(['HelperRes', 'ViewRes']); + + const activityFeedEventName = useActivityFeedEventName(); + const changedFieldNames = useChangedFieldNames(); + const entryTypeName = useEntryTypeName(); + + const mutedUsers = useMutedUsers(); + if (entry.author && mutedUsers.includes(entry.author.id)) return <>; + + return ( +
+ {entry.author ? ( - {entry.archivedVersion.changedFields && - entry.archivedVersion.changedFields.length > 0 && ( - - ( - {entry.archivedVersion.changedFields - .map((changedField) => - changedFieldNames( - EntryType[ - entry.entry.entryType as keyof typeof EntryType - ], - changedField, - ), - ) - .join(', ')} - ) - - )} - {entry.archivedVersion.notes && ( - <> - {' '} - "{entry.archivedVersion.notes}" - - )}{' '} - {entry.entry.entryType !== 'SongList' && - entry.entry.entryType !== 'ReleaseEvent' && ( - - ( - - {t('ViewRes:Misc.Details')} - - ) - - )} + - - )} - - {entry.createDate} - -
- {entry.entry.mainPicture && - (entry.entry.mainPicture.urlTinyThumb || - entry.entry.mainPicture.urlSmallThumb) && ( - - thumb - - )} -
-

- - {entry.entry.name} - - {entryTypeName(entry.entry) && ( - <> - {' '} - ({entryTypeName(entry.entry)}) - + ) : ( + {t('HelperRes:ActivityFeedHelper.Someone')} + )}{' '} + {activityFeedEventName(entry)} + {showDetails && entry.archivedVersion && ( + <> + {' '} + + {entry.archivedVersion.changedFields && + entry.archivedVersion.changedFields.length > 0 && ( + + ( + {entry.archivedVersion.changedFields + .map((changedField) => + changedFieldNames( + EntryType[ + entry.entry.entryType as keyof typeof EntryType + ], + changedField, + ), + ) + .join(', ')} + ) + + )} + {entry.archivedVersion.notes && ( + <> + {' '} + "{entry.archivedVersion.notes}" + + )}{' '} + {entry.entry.entryType !== 'SongList' && + entry.entry.entryType !== 'ReleaseEvent' && ( + + ( + + {t('ViewRes:Misc.Details')} + + ) + + )} + + + )} + + {entry.createDate} + +
+ {entry.entry.mainPicture && + (entry.entry.mainPicture.urlTinyThumb || + entry.entry.mainPicture.urlSmallThumb) && ( + + thumb + )} -

+
+

+ + {entry.entry.name} + + {entryTypeName(entry.entry) && ( + <> + {' '} + ({entryTypeName(entry.entry)}) + + )} +

- {entry.entry.artistString && {entry.entry.artistString}} + {entry.entry.artistString && ( + {entry.entry.artistString} + )} +
-
- ); -}; + ); + }, +); diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/ArchivedEntry/ArchivedObjectVersions.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/ArchivedEntry/ArchivedObjectVersions.tsx index 2379652516..ff346703fe 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/ArchivedEntry/ArchivedObjectVersions.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/ArchivedEntry/ArchivedObjectVersions.tsx @@ -5,7 +5,9 @@ import { useReasonNames } from '@/Components/useReasonNames'; import { ArchivedVersionContract } from '@/DataContracts/Versioning/ArchivedVersionContract'; import { EntryType } from '@/Models/EntryType'; import { LoginManager } from '@/Models/LoginManager'; +import { useMutedUsers } from '@/MutedUsersContext'; import classNames from 'classnames'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -18,7 +20,7 @@ interface ArchivedObjectVersionRowProps { entryType: EntryType; } -const ArchivedObjectVersionRow = React.memo( +const ArchivedObjectVersionRow = observer( ({ archivedVersion, linkFunc, @@ -29,6 +31,14 @@ const ArchivedObjectVersionRow = React.memo( const reasonNames = useReasonNames(); const changedFieldNames = useChangedFieldNames(); + const mutedUsers = useMutedUsers(); + if ( + archivedVersion.author && + mutedUsers.includes(archivedVersion.author.id) + ) { + return <>; + } + return ( diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/CommentBodyLarge.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/CommentBodyLarge.tsx index 38972b19fd..0fb6de887b 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/CommentBodyLarge.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/CommentBodyLarge.tsx @@ -4,7 +4,9 @@ import { ProfileIcon } from '@/Components/Shared/Partials/User/ProfileIcon'; import { UserLink } from '@/Components/Shared/Partials/User/UserLink'; import { CommentContract } from '@/DataContracts/CommentContract'; import { LoginManager } from '@/Models/LoginManager'; +import { useMutedUsers } from '@/MutedUsersContext'; import { EntryUrlMapper } from '@/Shared/EntryUrlMapper'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -16,12 +18,15 @@ interface CommentBodyLargeProps { alwaysAllowDelete?: boolean; } -export const CommentBodyLarge = React.memo( +export const CommentBodyLarge = observer( ({ contract, allowDelete, alwaysAllowDelete = false, }: CommentBodyLargeProps): React.ReactElement => { + const mutedUsers = useMutedUsers(); + if (mutedUsers.includes(contract.author.id)) return <>; + return (
{ const { t } = useTranslation(['ViewRes']); + const mutedUsers = useMutedUsers(); + if (mutedUsers.includes(commentKnockoutStore.author.id)) return <>; + return (
{ + const authorIds = uniq(entry.comments.map((comment) => comment.author.id)); + + const mutedUsers = useMutedUsers(); + if (authorIds.length === 1 && mutedUsers.includes(authorIds[0])) + return <>; + return (
diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/PrintComment.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/PrintComment.tsx index 4247f7d38e..6546d7dcc5 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/PrintComment.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/Comment/PrintComment.tsx @@ -5,6 +5,8 @@ import { UserIconLink_UserForApiContract } from '@/Components/Shared/Partials/Us import { truncateWithEllipsis } from '@/Components/truncateWithEllipsis'; import { CommentContract } from '@/DataContracts/CommentContract'; import { LoginManager } from '@/Models/LoginManager'; +import { useMutedUsers } from '@/MutedUsersContext'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,36 +19,41 @@ interface PrintCommentProps { maxLength?: number; } -export const PrintComment = ({ - contract, - allowDelete, - alwaysAllowDelete = false, - maxLength = 2147483647, -}: PrintCommentProps): React.ReactElement => { - const { t } = useTranslation(['ViewRes']); +export const PrintComment = observer( + ({ + contract, + allowDelete, + alwaysAllowDelete = false, + maxLength = 2147483647, + }: PrintCommentProps): React.ReactElement => { + const { t } = useTranslation(['ViewRes']); - return ( -
-

- {/* eslint-disable-next-line react/jsx-pascal-case */} - + const mutedUsers = useMutedUsers(); + if (mutedUsers.includes(contract.author.id)) return <>; - {(alwaysAllowDelete || - (allowDelete && loginManager.canDeleteComment(contract))) && ( - <> - -{' '} - - {t('ViewRes:Shared.Delete')} - - - )} - - {contract.created} - -

- -
- ); -}; + return ( +
+

+ {/* eslint-disable-next-line react/jsx-pascal-case */} + + + {(alwaysAllowDelete || + (allowDelete && loginManager.canDeleteComment(contract))) && ( + <> + -{' '} + + {t('ViewRes:Shared.Delete')} + + + )} + + {contract.created} + +

+ +
+ ); + }, +); diff --git a/VocaDbWeb/Scripts/Components/Shared/Partials/LeftMenu.tsx b/VocaDbWeb/Scripts/Components/Shared/Partials/LeftMenu.tsx index 28492c3dea..8f2d16529e 100644 --- a/VocaDbWeb/Scripts/Components/Shared/Partials/LeftMenu.tsx +++ b/VocaDbWeb/Scripts/Components/Shared/Partials/LeftMenu.tsx @@ -82,7 +82,7 @@ export const LeftMenu = observer( (): React.ReactElement => { const { t } = useTranslation(['ViewRes']); - const { vdbPlayer } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); return (
{ const embedPVPreviewRef = React.useRef(undefined!); - const { vdbPlayer, playQueue } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); + const playQueue = usePlayQueue(); const handleResize = React.useCallback(() => { if (!allowInline) return; diff --git a/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayer.tsx b/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayer.tsx index 1948207434..1859cc526d 100644 --- a/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayer.tsx +++ b/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayer.tsx @@ -10,7 +10,10 @@ import { songleWidgetHeight, } from '@/Components/VdbPlayer/SongleWidget'; import { VdbPlayerConsole } from '@/Components/VdbPlayer/VdbPlayerConsole'; -import { useVdbPlayer } from '@/Components/VdbPlayer/VdbPlayerContext'; +import { + usePlayQueue, + useVdbPlayer, +} from '@/Components/VdbPlayer/VdbPlayerContext'; import { PVContract } from '@/DataContracts/PVs/PVContract'; import { EntryUrlMapper } from '@/Shared/EntryUrlMapper'; import { PlayQueueEntryContract } from '@/Stores/VdbPlayer/PlayQueueRepository'; @@ -46,7 +49,8 @@ const repeatIcons: Record = { const PlayerCenterControls = observer( (): React.ReactElement => { const diva = useNostalgicDiva(); - const { vdbPlayer, playQueue } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); + const playQueue = usePlayQueue(); const handlePrevious = React.useCallback(async () => { if (playQueue.hasPreviousItem) { @@ -248,7 +252,8 @@ const PlayerRightControls = observer( const { t } = useTranslation(['ViewRes.Search']); const diva = useNostalgicDiva(); - const { vdbPlayer, playQueue } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); + const playQueue = usePlayQueue(); const handleClickSkipBack10Seconds = React.useCallback(async () => { const { currentTime } = playQueue; @@ -421,7 +426,8 @@ interface PVPlayerProps { const EmbedPVWrapper = observer( ({ pv }: PVPlayerProps): React.ReactElement => { const diva = useNostalgicDiva(); - const { vdbPlayer, playQueue } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); + const playQueue = usePlayQueue(); const handleError = React.useCallback((event: any) => { VdbPlayerConsole.error('error', event); @@ -515,7 +521,8 @@ const EmbedPVWrapper = observer( const MiniPlayer = observer( (): React.ReactElement => { - const { vdbPlayer, playQueue } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); + const playQueue = usePlayQueue(); return (
{ const diva = useNostalgicDiva(); - const { vdbPlayer } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); const ref = React.useRef(undefined!); const handleClick = React.useCallback( @@ -605,7 +612,7 @@ const SeekBar = observer( const BottomBar = observer( (): React.ReactElement => { - const { vdbPlayer } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); // Code from: https://github.com/elastic/eui/blob/e07ee756120607b338d522ee8bcedd4228d02673/src/components/bottom_bar/bottom_bar.tsx#L137. React.useEffect(() => { @@ -651,7 +658,8 @@ export const VdbPlayer = observer( VdbPlayerConsole.debug('VdbPlayer'); const diva = useNostalgicDiva(); - const { vdbPlayer, playQueue } = useVdbPlayer(); + const vdbPlayer = useVdbPlayer(); + const playQueue = usePlayQueue(); useLocalStorageStateStore('PlayQueueStore', playQueue); useLocalStorageStateStore('SkipListStore', playQueue.skipList); diff --git a/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayerContext.tsx b/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayerContext.tsx index 90c0ebff42..934bbeaaf9 100644 --- a/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayerContext.tsx +++ b/VocaDbWeb/Scripts/Components/VdbPlayer/VdbPlayerContext.tsx @@ -29,13 +29,16 @@ const playQueueRepoFactory = new PlayQueueRepositoryFactory( userRepo, ); -export interface VdbPlayerContextProps { - vdbPlayer: VdbPlayerStore; - playQueue: PlayQueueStore; -} +const VdbPlayerContext = React.createContext(undefined!); -export const VdbPlayerContext = React.createContext( - undefined!, +const vdbPlayerStore = new VdbPlayerStore( + vdb.values, + albumRepo, + eventRepo, + songRepo, + playQueueRepoFactory, + artistRepo, + tagRepo, ); interface VdbPlayerProviderProps { @@ -45,28 +48,18 @@ interface VdbPlayerProviderProps { export const VdbPlayerProvider = ({ children, }: VdbPlayerProviderProps): React.ReactElement => { - const [vdbPlayer] = React.useState( - () => - new VdbPlayerStore( - vdb.values, - albumRepo, - eventRepo, - songRepo, - playQueueRepoFactory, - artistRepo, - tagRepo, - ), - ); - return ( - + {children} ); }; -export const useVdbPlayer = (): VdbPlayerContextProps => { +export const useVdbPlayer = (): VdbPlayerStore => { return React.useContext(VdbPlayerContext); }; + +export const usePlayQueue = (): PlayQueueStore => { + const vdbPlayer = useVdbPlayer(); + return vdbPlayer.playQueue; +}; diff --git a/VocaDbWeb/Scripts/DataContracts/ActivityEntry/ActivityEntryContract.ts b/VocaDbWeb/Scripts/DataContracts/ActivityEntry/ActivityEntryContract.ts index 75c62036b8..66774a9746 100644 --- a/VocaDbWeb/Scripts/DataContracts/ActivityEntry/ActivityEntryContract.ts +++ b/VocaDbWeb/Scripts/DataContracts/ActivityEntry/ActivityEntryContract.ts @@ -5,7 +5,7 @@ import { EntryEditEvent } from '@/Models/ActivityEntries/EntryEditEvent'; export interface ActivityEntryContract { archivedVersion?: ArchivedVersionContract; - author: UserApiContract; + author?: UserApiContract; createDate: string; editEvent: EntryEditEvent; entry: EntryContract; diff --git a/VocaDbWeb/Scripts/MutedUsersContext.tsx b/VocaDbWeb/Scripts/MutedUsersContext.tsx new file mode 100644 index 0000000000..b03e02235b --- /dev/null +++ b/VocaDbWeb/Scripts/MutedUsersContext.tsx @@ -0,0 +1,27 @@ +import { MutedUsersStore } from '@/Stores/MutedUsersStore'; +import { useLocalStorageStateStore } from '@vocadb/route-sphere'; +import React from 'react'; + +const MutedUsersContext = React.createContext(undefined!); + +const mutedUsersStore = new MutedUsersStore(); + +interface MutedUsersProviderProps { + children?: React.ReactNode; +} + +export const MutedUsersProvider = ({ + children, +}: MutedUsersProviderProps): React.ReactElement => { + useLocalStorageStateStore('MutedUsersStore', mutedUsersStore); + + return ( + + {children} + + ); +}; + +export const useMutedUsers = (): MutedUsersStore => { + return React.useContext(MutedUsersContext); +}; diff --git a/VocaDbWeb/Scripts/Pages/Album/AlbumBasicInfo.tsx b/VocaDbWeb/Scripts/Pages/Album/AlbumBasicInfo.tsx index e3f5f9c9e2..fa8784cc60 100644 --- a/VocaDbWeb/Scripts/Pages/Album/AlbumBasicInfo.tsx +++ b/VocaDbWeb/Scripts/Pages/Album/AlbumBasicInfo.tsx @@ -28,6 +28,7 @@ import { TagList } from '@/Components/Shared/Partials/TagList'; import { ProfileIcon } from '@/Components/Shared/Partials/User/ProfileIcon'; import { UserLink } from '@/Components/Shared/Partials/User/UserLink'; import { AlbumDetailsForApi } from '@/DataContracts/Album/AlbumDetailsForApi'; +import { AlbumReviewContract } from '@/DataContracts/Album/AlbumReviewContract'; import { ArtistLinkContract } from '@/DataContracts/Song/ArtistLinkContract'; import { DateTimeHelper } from '@/Helpers/DateTimeHelper'; import JQueryUIButton from '@/JQueryUI/JQueryUIButton'; @@ -38,6 +39,7 @@ import { LoginManager } from '@/Models/LoginManager'; import { PVService } from '@/Models/PVs/PVService'; import { SongVoteRating } from '@/Models/SongVoteRating'; import { SongType } from '@/Models/Songs/SongType'; +import { useMutedUsers } from '@/MutedUsersContext'; import { AlbumDetailsTabs } from '@/Pages/Album/AlbumDetailsRoutes'; import { EntryUrlMapper } from '@/Shared/EntryUrlMapper'; import { AlbumDetailsStore } from '@/Stores/Album/AlbumDetailsStore'; @@ -50,6 +52,59 @@ import { Link } from 'react-router-dom'; const loginManager = new LoginManager(vdb.values); +interface LatestAlbumReviewProps { + latestReview: AlbumReviewContract; + latestReviewRatingScore: number; +} + +const LatestAlbumReview = observer( + ({ + latestReview, + latestReviewRatingScore, + }: LatestAlbumReviewProps): React.ReactElement => { + const mutedUsers = useMutedUsers(); + if (mutedUsers.includes(latestReview.user.id)) return <>; + + return ( +
+ + + + +
+
+ |{' '} + {latestReview.date} +
+

+ +

+ + {latestReviewRatingScore > 0 && ( + + )} + +
+ {latestReview.title && ( +

{latestReview.title}

+ )} + +
+ +
+
+
+
+ ); + }, +); + interface AlbumBasicInfoProps { model: AlbumDetailsForApi; albumDetailsStore: AlbumDetailsStore; @@ -554,50 +609,10 @@ const AlbumBasicInfo = observer( <>

{t('ViewRes.Album:Details.LatestReview')}

-
- - - - -
-
- {' '} - |{' '} - - {model.latestReview.date} - -
-

- -

- - {model.latestReviewRatingScore > 0 && ( - - )} - -
- {model.latestReview.title && ( -

- {model.latestReview.title} -

- )} - -
- -
-
-
-
+

{ + const { t } = useTranslation(['ViewRes']); + + const mutedUsers = useMutedUsers(); + if (mutedUsers.includes(review.user.id)) return <>; + + return ( +

+ + +
+
+ {review.languageCode}{' '} + | {review.date} + {review.canBeEdited && ( + <> +    + + albumDetailsStore.reviewsStore.beginEditReview(review) + } + href="#" + className="textLink editLink" + > + {t('ViewRes:Shared.Edit')} + + + )} + {review.canBeDeleted && ( + <> +    + { + if ( + window.confirm( + 'Are you sure you want to delete this review?' /* TODO: localize */, + ) + ) { + albumDetailsStore.reviewsStore.deleteReview(review); + } + }} + href="#" + className="textLink deleteLink" + > + {t('ViewRes:Shared.Delete')} + + + )} +
+

+ +

+ + + {albumDetailsStore.reviewsStore + .ratingStars( + albumDetailsStore.reviewsStore.getRatingForUser(review.user.id), + ) + .map((ratingStar, index) => ( + + {index > 0 && ' '} + {/* eslint-disable-next-line jsx-a11y/alt-text */} + + + ))} + + + {albumDetailsStore.reviewsStore.editReviewStore === review ? ( +
{ + e.preventDefault(); + + albumDetailsStore.reviewsStore.saveEditedReview(); + }} + > + + runInAction(() => { + review.editedTitle = e.target.value; + }) + } + type="text" + className="input-xlarge" + maxLength={200} + placeholder="Title" /* TODO: localize */ + /> +
+