diff --git a/client/src/itemHoverMenu.tsx b/client/src/itemHoverMenu.tsx
index d2e739d5..968f74a8 100644
--- a/client/src/itemHoverMenu.tsx
+++ b/client/src/itemHoverMenu.tsx
@@ -138,7 +138,6 @@ export const ItemHoverMenu = ({
}
`}
>
- {/*TODO starred messages*/}
diff --git a/client/src/modal.tsx b/client/src/modal.tsx
index 6a790aec..f10f4e66 100644
--- a/client/src/modal.tsx
+++ b/client/src/modal.tsx
@@ -79,7 +79,7 @@ export const useConfirmModal = (
-
+
{item.editHistory && item.editHistory.length > 0 && (
- Edited
)}
diff --git a/client/src/pinboard.tsx b/client/src/pinboard.tsx
index e2fc37dc..276c3d2a 100644
--- a/client/src/pinboard.tsx
+++ b/client/src/pinboard.tsx
@@ -21,6 +21,8 @@ import { ModalBackground } from "./modal";
import { maybeConstructPayloadAndType } from "./types/PayloadAndType";
import { useTourProgress, useTourStepRef } from "./tour/tourState";
import { Reply } from "./reply";
+import { isPinboardData } from "shared/graphql/extraTypes";
+
export interface ItemsMap {
[id: string]: Item | PendingItem;
}
@@ -66,6 +68,9 @@ export const Pinboard = ({
setUnreadFlag,
addManuallyOpenedPinboardId,
+
+ preselectedPinboard,
+ setStarredMessages,
} = useGlobalStateContext();
const sendTelemetryEvent = useContext(TelemetryContext);
@@ -145,6 +150,17 @@ export const Pinboard = ({
const lastItemIndex = items.length - 1;
const lastItem = items[lastItemIndex];
+ useEffect(() => {
+ if (
+ isPinboardData(preselectedPinboard) &&
+ preselectedPinboard.id === pinboardId
+ ) {
+ setStarredMessages(
+ items.filter((item) => item.isStarred && !item.deletedAt)
+ );
+ }
+ }, [items, preselectedPinboard]);
+
const initialLastItemSeenByUsersQuery = useQuery(
gqlGetLastItemSeenByUsers(pinboardId),
{
diff --git a/client/src/scrollableItems.tsx b/client/src/scrollableItems.tsx
index be422a30..5126f576 100644
--- a/client/src/scrollableItems.tsx
+++ b/client/src/scrollableItems.tsx
@@ -27,6 +27,7 @@ import { PendingItem } from "./types/PendingItem";
import { UserLookup } from "./types/UserLookup";
import { PINBOARD_ITEM_ID_QUERY_PARAM } from "../../shared/constants";
import { useTourProgress } from "./tour/tourState";
+import { useGlobalStateContext } from "./globalState";
interface ScrollableItemsProps {
items: Array
;
@@ -75,6 +76,8 @@ export const ScrollableItems = ({
setMaybeEditingItemId,
setMaybeReplyingToItemId,
}: ScrollableItemsProps) => {
+ const { setMaybeScrollToItem } = useGlobalStateContext();
+
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
const lastItemSeenByUsersForItemIDLookup = useMemo(
@@ -218,13 +221,29 @@ export const ScrollableItems = ({
const scrollToItem = useCallback(
(itemID: string) => {
const targetElement = refMap.current[itemID];
- targetElement?.scrollIntoView({ behavior: "smooth" });
- targetElement.style.animation = "highlight-item 0.5s linear infinite"; // see panel.tsx for definition of 'highlight-item' animation
- setTimeout(() => (targetElement.style.animation = ""), 2500);
+ if (targetElement) {
+ targetElement.scrollIntoView({ behavior: "smooth" });
+ targetElement.style.animation = "highlight-item 0.5s linear infinite"; // see panel.tsx for definition of 'highlight-item' animation
+ setTimeout(() => (targetElement.style.animation = ""), 2500);
+ } else {
+ console.error(
+ "Tried to scroll to item with ID",
+ itemID,
+ "but could not find it in the refMap"
+ );
+ }
},
[refMap.current]
);
+ useEffect(
+ () =>
+ setMaybeScrollToItem(
+ () => scrollToItem // since this is a setState, we need to wrap it in a function to avoid it evaluating immediately because of setState's overloaded signature
+ ),
+ [scrollToItem]
+ );
+
useLayoutEffect(() => {
if (
!hasProcessedItemIdInURL &&
@@ -301,6 +320,7 @@ export const ScrollableItems = ({
item.claimedByEmail,
item.editHistory,
item.deletedAt,
+ item.isStarred,
"pending" in item,
userLookup,
lastItemSeenByUsersForItemIDLookup,
diff --git a/client/src/starred/starredControl.tsx b/client/src/starred/starredControl.tsx
new file mode 100644
index 00000000..f9912d0e
--- /dev/null
+++ b/client/src/starred/starredControl.tsx
@@ -0,0 +1,71 @@
+import { useMutation } from "@apollo/client";
+import { css } from "@emotion/react";
+import { neutral, space } from "@guardian/source-foundations";
+import { SvgStar, SvgStarOutline } from "@guardian/source-react-components";
+import React from "react";
+import { Item } from "shared/graphql/graphql";
+import { composer } from "../../colours";
+import { gqlSetIsStarred } from "../../gql";
+import { useConfirmModal } from "../modal";
+import { scrollbarsCss } from "../styling";
+
+export const STARRED_CONTROL_CLASS_NAME = "starred-control";
+
+interface StarredControlProps {
+ item: Item;
+}
+
+export const StarredControl = ({
+ item: { id, isStarred, message },
+}: StarredControlProps) => {
+ const [setIsStarred] = useMutation(gqlSetIsStarred);
+ const [confirmModalElement, confirm] = useConfirmModal(
+
+
+ Are you sure you want
+
to {isStarred ? "unstar" : "star"} this item?
+
+
+ {message}
+
+
+ );
+ const toggleIsStarred = () => {
+ confirm().then((isConfirmed) => {
+ isConfirmed &&
+ setIsStarred({ variables: { itemId: id, isStarred: !isStarred } });
+ });
+ };
+ return (
+
+ {confirmModalElement}
+
+
+ );
+};
diff --git a/client/src/starred/starredMessages.tsx b/client/src/starred/starredMessages.tsx
new file mode 100644
index 00000000..db17b4c0
--- /dev/null
+++ b/client/src/starred/starredMessages.tsx
@@ -0,0 +1,142 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { Item } from "shared/graphql/graphql";
+import root from "react-shadow/emotion";
+import { UserLookup } from "../types/UserLookup";
+import { FormattedDateTime } from "../formattedDateTime";
+import { css } from "@emotion/react";
+import { agateSans } from "../../fontNormaliser";
+import { neutral, space } from "@guardian/source-foundations";
+import { pinboard } from "../../colours";
+import { useGlobalStateContext } from "../globalState";
+import { SvgStar } from "@guardian/source-react-components";
+
+export const STARRED_MESSAGES_HTML_TAG = "pinboard-starred-messages";
+
+const StarredItemDisplay = ({
+ item,
+ userLookup,
+ maybeScrollToItem,
+}: {
+ item: Item;
+ userLookup: UserLookup;
+ maybeScrollToItem: ((itemId: string) => void) | undefined;
+}) => {
+ const user = userLookup?.[item.userEmail];
+ const userDisplayName = user
+ ? `${user.firstName} ${user.lastName}` // TODO: Non-breaking space
+ : item.userEmail;
+ return (
+ {
+ event.stopPropagation();
+ event.preventDefault();
+ setTimeout(() => maybeScrollToItem(item.id), 250);
+ })
+ }
+ >
+
+ {item.message}
+
+
+ {userDisplayName}
+
+
+
+
+
+ );
+};
+
+interface StarredMessagesProps {
+ maybeStarredMessages: Item[] | undefined;
+ userLookup: UserLookup;
+ maybeScrollToItem: ((itemId: string) => void) | undefined;
+}
+
+const StarredMessages = ({
+ maybeStarredMessages,
+ userLookup,
+ maybeScrollToItem,
+}: StarredMessagesProps) => {
+ const { setIsExpanded, setActiveTab } = useGlobalStateContext();
+ return !maybeStarredMessages ? null : (
+
+ setIsExpanded(true)}
+ >
+ {maybeStarredMessages.map((item) => (
+ {
+ setIsExpanded(true);
+ setActiveTab("chat");
+ maybeScrollToItem(itemId);
+ })
+ }
+ />
+ ))}
+
+
+ If you need to leave an important message please use Pinboard
+ 'Starred Messages' rather than notes.{" "}
+
+
+ Click here to open Pinboard, then simply send a message and then click
+ the to the left of your message.
+
+ You can also star other's messages if you think they're
+ important.
+
+
+
+ );
+};
+
+interface StarredMessagesPortalProps extends StarredMessagesProps {
+ node: Element;
+}
+
+export const StarredMessagesPortal = ({
+ node,
+ ...props
+}: StarredMessagesPortalProps) =>
+ ReactDOM.createPortal(, node);
+
+const detailCSS = css`
+ margin-left: 7px;
+ vertical-align: sub;
+`;
diff --git a/client/src/tour/tourMessageReplies.ts b/client/src/tour/tourMessageReplies.ts
index 1e67dcd6..cc620b0e 100644
--- a/client/src/tour/tourMessageReplies.ts
+++ b/client/src/tour/tourMessageReplies.ts
@@ -29,6 +29,7 @@ const buildMessageItem = (
claimedByEmail: null,
claimable: false,
pinboardId: demoPinboardData.id,
+ isStarred: false,
});
export const replyTo = (
diff --git a/client/src/tour/tourState.tsx b/client/src/tour/tourState.tsx
index c4049870..3d00f3dd 100644
--- a/client/src/tour/tourState.tsx
+++ b/client/src/tour/tourState.tsx
@@ -304,6 +304,7 @@ export const TourStateProvider: React.FC = ({ children }) => {
})),
groupMentions: [], //TODO - map variables.input.groupMentions to mention handle,
claimable: variables.input.claimable || false,
+ isStarred: false,
};
setSuccessfulSends((prevSuccessfulSends) => [
...prevSuccessfulSends,
diff --git a/client/src/util.ts b/client/src/util.ts
index 2650e8e8..87db7be2 100644
--- a/client/src/util.ts
+++ b/client/src/util.ts
@@ -13,11 +13,15 @@ export const getTooltipText = (
) => `WT: ${workingTitle}` + (headline ? `\nHL: ${headline}` : "");
export const formatDateTime = (
- timestamp: number,
- isPartOfSentence?: true,
- withAgo?: true
+ timestampStringOrEpochMillis: number | string,
+ isPartOfSentence?: boolean,
+ withAgo?: boolean
): string => {
const now = Date.now();
+ const timestamp =
+ typeof timestampStringOrEpochMillis === "string"
+ ? new Date(timestampStringOrEpochMillis).valueOf()
+ : timestampStringOrEpochMillis;
if (isThisYear(timestamp)) {
if (isToday(timestamp)) {
if (differenceInMinutes(now, timestamp) < 1) {
@@ -32,7 +36,7 @@ export const formatDateTime = (
return (
formatDistanceStrict(timestamp, now, {
roundingMethod: "floor",
- }).slice(0, -4) + (withAgo ? " ago" : "")
+ }).slice(0, -4) + (withAgo ? "s ago" : "")
);
}
return format(timestamp, "HH:mm");
diff --git a/client/test/formattedDateTime.test.ts b/client/test/formattedDateTime.test.ts
index 0592f1e4..a1711bda 100644
--- a/client/test/formattedDateTime.test.ts
+++ b/client/test/formattedDateTime.test.ts
@@ -28,6 +28,13 @@ test("display is correct if timestamp is between 2 min and 1 hr ago", () => {
expect(formatDateTime(twoMinsAgo)).toBe("2 min");
const lessThanHr = subMinutes(Date.now(), 59).valueOf();
expect(formatDateTime(lessThanHr)).toBe("59 min");
+ const pluralMinsWithAgo = subMinutes(Date.now(), 3).valueOf();
+ expect(formatDateTime(pluralMinsWithAgo, false, true)).toBe("3 mins ago");
+});
+
+test("display is correct if passed a string", () => {
+ const twoMinsAgoString = subMinutes(Date.now(), 2).toISOString();
+ expect(formatDateTime(twoMinsAgoString)).toBe("2 min");
});
test("display is correct if timestamp is 1 hr ago exactly", () => {
diff --git a/database-bridge-lambda/src/index.ts b/database-bridge-lambda/src/index.ts
index 1bd5c36e..5f1112fd 100644
--- a/database-bridge-lambda/src/index.ts
+++ b/database-bridge-lambda/src/index.ts
@@ -10,6 +10,7 @@ import {
getGroupPinboardIds,
getItemCounts,
listItems,
+ setIsStarred,
} from "./sql/Item";
import { Sql } from "../../shared/database/types";
import { listLastItemSeenByUsers, seenItem } from "./sql/LastItemSeenByUser";
@@ -42,6 +43,8 @@ const run = (
return deleteItem(sql, args, userEmail);
case "claimItem":
return claimItem(sql, args, userEmail);
+ case "setIsStarred":
+ return setIsStarred(sql, args, userEmail);
case "listItems":
return listItems(sql, args, userEmail);
case "seenItem":
diff --git a/database-bridge-lambda/src/sql/Item.ts b/database-bridge-lambda/src/sql/Item.ts
index efe977a7..8d3c8273 100644
--- a/database-bridge-lambda/src/sql/Item.ts
+++ b/database-bridge-lambda/src/sql/Item.ts
@@ -143,6 +143,19 @@ export const claimItem = (
};
});
+export const setIsStarred = async (
+ sql: Sql,
+ args: { itemId: string; isStarred: boolean },
+ userEmail: string
+) =>
+ sql`
+ UPDATE "Item"
+ SET
+ "isStarred" = ${args.isStarred}
+ WHERE "id" = ${args.itemId}
+ RETURNING ${fragmentItemFields(sql, userEmail)}
+ `.then((rows) => rows[0]);
+
export const getGroupPinboardIds = async (
sql: Sql,
userEmail: string
diff --git a/notifications-lambda/run.ts b/notifications-lambda/run.ts
index 98ad2dc2..d4bcd649 100644
--- a/notifications-lambda/run.ts
+++ b/notifications-lambda/run.ts
@@ -40,6 +40,7 @@ import { Item } from "shared/graphql/graphql";
relatedItemId: null,
editHistory: null,
deletedAt: null,
+ isStarred: false,
} satisfies Item,
users: [yourUser as UserWithWebPushSubscription],
});
diff --git a/shared/database/local/runDatabaseSetup.ts b/shared/database/local/runDatabaseSetup.ts
index d4ffe63b..b0400b8b 100644
--- a/shared/database/local/runDatabaseSetup.ts
+++ b/shared/database/local/runDatabaseSetup.ts
@@ -85,6 +85,8 @@ const runSetupTriggerSqlFile = (
getEmailLambdaFunctionName(stage),
EMAIL_DATABASE_TRIGGER_NAME
),
+ "add isStarred column to Item table": () =>
+ runSetupSqlFile(sql, "020-AddIsStarredColumnToItemTable.sql"),
};
const allSteps = async () => {
diff --git a/shared/database/local/setup/020-AddIsStarredColumnToItemTable.sql b/shared/database/local/setup/020-AddIsStarredColumnToItemTable.sql
new file mode 100644
index 00000000..309ac515
--- /dev/null
+++ b/shared/database/local/setup/020-AddIsStarredColumnToItemTable.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "Item"
+ ADD COLUMN "isStarred" BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/shared/graphql/operations.ts b/shared/graphql/operations.ts
index 5fcd874e..fc99e0ab 100644
--- a/shared/graphql/operations.ts
+++ b/shared/graphql/operations.ts
@@ -33,6 +33,7 @@ export const MUTATIONS = {
"editItem",
"deleteItem",
"claimItem",
+ "setIsStarred",
"seenItem",
"setWebPushSubscriptionForUser",
"addManuallyOpenedPinboardIds",
diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql
index cb1d8029..00cb0769 100644
--- a/shared/graphql/schema.graphql
+++ b/shared/graphql/schema.graphql
@@ -27,6 +27,7 @@ type Mutation {
editItem(itemId: String!, input: EditItemInput!): Item
deleteItem(itemId: String!): Item
claimItem(itemId: String!): Claimed
+ setIsStarred(itemId: String!, isStarred: Boolean!): Item
seenItem(input: LastItemSeenByUserInput!): LastItemSeenByUser
setWebPushSubscriptionForUser(webPushSubscription: AWSJSON): MyUser
addManuallyOpenedPinboardIds(pinboardId: String!, maybeEmailOverride: String): MyUser
@@ -37,7 +38,7 @@ type Mutation {
type Subscription {
onMutateItem(
pinboardId: String
- ): Item @aws_subscribe(mutations: ["createItem", "editItem", "deleteItem"])
+ ): Item @aws_subscribe(mutations: ["createItem", "editItem", "deleteItem", "setIsStarred"])
onClaimItem(
pinboardId: String
@@ -72,6 +73,7 @@ type Item {
relatedItemId: String
editHistory: [AWSDateTime!]
deletedAt: AWSDateTime
+ isStarred: Boolean!
}
type LastItemSeenByUser {