Skip to content

Commit

Permalink
Added presence list
Browse files Browse the repository at this point in the history
  • Loading branch information
ilyabo committed Sep 8, 2024
1 parent 1e7ee1a commit d8b2561
Show file tree
Hide file tree
Showing 14 changed files with 269 additions and 19 deletions.
30 changes: 30 additions & 0 deletions assets/js/components/ui/separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import {cn} from "./utils";

const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{className, orientation = "horizontal", decorative = true, ...props},
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;

export {Separator};
6 changes: 5 additions & 1 deletion assets/js/map/map-controls-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {ToolbarContainer} from "./toolbar-container";
import {UndoContainer} from "./undo-container";
import {useFitBounds} from "./use-fit-bounds";
import {useGeolocation} from "./use-geolocation";
import PresenceContainer from "./presence-container";

type Props = {
mapRef: React.RefObject<MapRef>;
Expand All @@ -26,7 +27,10 @@ const MapControlsContainer: FC<Props> = (props) => {
<UndoContainer />
</div>
<div className="absolute flex flex-col gap-1 top-2 right-2 items-end">
<ShareContainer />
<div className="flex gap-1 items-center">
<PresenceContainer />
<ShareContainer />
</div>
<ToolbarButton
isSelected={false}
tooltipText={"Locate me"}
Expand Down
72 changes: 72 additions & 0 deletions assets/js/map/presence-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, {FC, Fragment, useMemo} from "react";
import {useAppStore} from "../store/store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import {Separator} from "../components/ui/separator";

const PresenceContainer: FC = () => {
const currentUserId = useAppStore((state) => state.userId);
const presence = useAppStore((state) => state.presence);
const users = useMemo(
() =>
Object.entries(presence).map(([userId, {metas}]) => ({
userId,
name: metas[0].name,
color: metas[0].color,
})),
[presence]
);
return (
<DropdownMenu>
<DropdownMenuTrigger>
<div className="flex">
{users.map(({userId, color}) => (
<svg
key={userId}
className="ml-[-18px] cursor-pointer"
width="30"
height="30"
viewBox="0 0 20 20"
>
<circle
cx="10"
cy="10"
r="8"
fill={color}
stroke="#fff"
strokeWidth={0.7}
/>
</svg>
))}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{users.map(({userId, color, name}, i) => (
<Fragment key={userId}>
<DropdownMenuItem
className="text-xs"
disabled={userId === currentUserId}
>
<div
className="rounded-full h-3 w-3 mr-2"
style={{backgroundColor: color}}
/>
{name ?? userId === currentUserId ? "You" : "New user"}
</DropdownMenuItem>
{i < users.length - 1 ? (
<div className="px-1 pb-1">
<Separator />
</div>
) : null}
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

export default PresenceContainer;
6 changes: 4 additions & 2 deletions assets/js/map/share-container.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Separator} from "@radix-ui/react-dropdown-menu";
import {Separator} from "../components/ui/separator";
import {PinIcon, Share2Icon, XIcon} from "lucide-react";
import React, {FC, useCallback, useEffect, useState, MouseEvent} from "react";
import {Button} from "../components/ui/button";
Expand Down Expand Up @@ -78,7 +78,9 @@ const ShareContainer: FC<Props> = (props) => {
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange} modal={!isShared}>
<DropdownMenuTrigger asChild>
<Button className="text-xs bg-blue-700 hover:bg-blue-600">Share</Button>
<Button size="sm" className="text-xs bg-blue-700 hover:bg-blue-600">
Share
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[200px] flex flex-col items-center">
<DropdownMenuItem
Expand Down
56 changes: 48 additions & 8 deletions assets/js/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,29 @@ import {FeatureOf, Polygon} from "@deck.gl-community/editable-layers";
import {ViewState} from "react-map-gl";
import {interpolateRainbow, rgb} from "d3";
import {create} from "zustand";
import {produce} from "immer";

import * as Y from "yjs";
import {Socket} from "phoenix";
import * as Phoenix from "phoenix";
import {IndexeddbPersistence} from "y-indexeddb";
import {DrawingMode} from "../map/types";
import {createId} from "@paralleldrive/cuid2";
import {generateColorFromId} from "./utils";

export type PhoenixSocket = typeof Socket.prototype;
export type PhoenixSocket = typeof Phoenix.Socket.prototype;
export type PolygonFeature = FeatureOf<Polygon>;
export type PresenseState = Record<
string, // user_id
{
metas: [
{color?: string; name?: string; phx_ref: string; online_at: number}
];
}
>;

interface DrawingState {
presence: PresenseState;
userId: string;
ydoc: Y.Doc;
yfeaturesUndo: Y.UndoManager;
shouldFitViewport: boolean | undefined; // whether to fit the viewport to the features
Expand Down Expand Up @@ -42,7 +54,7 @@ interface DrawingState {
setHexResolution: (resolution: number) => void;
}

const LOCAL_DOCUMENT_GUID = "drawing";
const LOCAL_DOCUMENT_GUID = "__local__";

export const INITIAL_MAP_VIEW_STATE: ViewState = {
latitude: 0,
Expand Down Expand Up @@ -76,7 +88,15 @@ export const useAppStore = create<DrawingState>((set, get) => {
});
};

let userId = localStorage.getItem("user_id");
if (!userId) {
userId = createId();
localStorage.setItem("user_id", userId);
}

return {
userId,
presence: {},
...initYDoc(), // TODO avoid local initialization if guid is provided
mapViewState: INITIAL_MAP_VIEW_STATE,
// Array of features extracted from yarray used for rendering
Expand Down Expand Up @@ -105,18 +125,21 @@ export const useAppStore = create<DrawingState>((set, get) => {
}

if (guid) {
const {ydoc, yfeaturesUndo, isShared} = get();
const {ydoc, yfeaturesUndo, isShared, userId} = get();
if (isShared) return;

const socket = new Socket(
"/socket" /*, {params: {token: window.userToken}}*/
);
const socket = new Phoenix.Socket("/socket", {params: {userId}});
// @ts-ignore
socket.connect();
set({socket});

const channel = socket.channel(
`drawing:${guid}`,
Y.encodeStateAsUpdate(ydoc).buffer // Send the initial state to the server
// Y.encodeStateAsUpdate(ydoc).buffer // Send the initial state to the server
{
userName: null,
userColor: generateColorFromId(userId),
}
);
channel
.join()
Expand Down Expand Up @@ -150,6 +173,23 @@ export const useAppStore = create<DrawingState>((set, get) => {
Y.applyUpdate(ydoc, update, "remote"); // Mark the update as coming from "remote"
});

// const presence = new Phoenix.Presence(channel);
// const updatePresence = () => set({presence: presence.state});
// presence.onSync(updatePresence);
// presence.onLeave(updatePresence);
// presence.onJoin(updatePresence);
channel.on("presence_state", (state) => {
set({presence: state});
});
channel.on("presence_diff", ({leaves, joins}) => {
set((state) =>
produce(state, (draft) => {
for (const id in leaves) delete draft.presence[id];
for (const id in joins) draft.presence[id] = joins[id];
})
);
});

set({isShared: true});
}
},
Expand Down
14 changes: 14 additions & 0 deletions assets/js/store/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,17 @@ export function findLastLabelLayerId(style) {
export function isValudGuid(guid: string | undefined) {
return guid && /^[a-z0-9]{24}$/.test(guid);
}

export function generateColorFromId(id: string): string {
const hash = hashString(id);
const hue = hash % 360;
return `hsl(${hue}, 100%, 50%)`;
}

function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
}
2 changes: 2 additions & 0 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.1.2",
Expand All @@ -16,6 +17,7 @@
"d3": "^7.9.0",
"deck.gl": "^9.0.19",
"h3-js": "^4.1.0",
"immer": "^10.1.1",
"lucide-react": "^0.438.0",
"maplibre-gl": "^4.4.0",
"phoenix": "^1.7.12",
Expand Down
28 changes: 28 additions & 0 deletions assets/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,25 @@ __metadata:
languageName: node
linkType: hard

"@radix-ui/react-separator@npm:^1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-separator@npm:1.1.0"
dependencies:
"@radix-ui/react-primitive": "npm:2.0.0"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/0ca9e25db27b6b001f3c0c50b2df9d6cf070b949f183043e263115d694a25b7268fecd670572469a512e556deca25ebb08b3aec4a870f0309eed728eef19ab8a
languageName: node
linkType: hard

"@radix-ui/react-slider@npm:^1.1.2":
version: 1.1.2
resolution: "@radix-ui/react-slider@npm:1.1.2"
Expand Down Expand Up @@ -4415,6 +4434,13 @@ __metadata:
languageName: node
linkType: hard

"immer@npm:^10.1.1":
version: 10.1.1
resolution: "immer@npm:10.1.1"
checksum: 10c0/b749e10d137ccae91788f41bd57e9387f32ea6d6ea8fd7eb47b23fd7766681575efc7f86ceef7fe24c3bc9d61e38ff5d2f49c2663b2b0c056e280a4510923653
languageName: node
linkType: hard

"imurmurhash@npm:^0.1.4":
version: 0.1.4
resolution: "imurmurhash@npm:0.1.4"
Expand Down Expand Up @@ -5706,6 +5732,7 @@ __metadata:
"@paralleldrive/cuid2": "npm:^2.2.2"
"@radix-ui/react-dropdown-menu": "npm:^2.0.6"
"@radix-ui/react-popover": "npm:^1.0.7"
"@radix-ui/react-separator": "npm:^1.1.0"
"@radix-ui/react-slider": "npm:^1.1.2"
"@radix-ui/react-slot": "npm:^1.0.2"
"@radix-ui/react-tooltip": "npm:^1.1.2"
Expand All @@ -5718,6 +5745,7 @@ __metadata:
d3: "npm:^7.9.0"
deck.gl: "npm:^9.0.19"
h3-js: "npm:^4.1.0"
immer: "npm:^10.1.1"
lucide-react: "npm:^0.438.0"
maplibre-gl: "npm:^4.4.0"
phoenix: "npm:^1.7.12"
Expand Down
9 changes: 7 additions & 2 deletions lib/mapcanv/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ defmodule MapCanv.Application do
MapCanvWeb.Telemetry,
{DNSCluster, query: Application.get_env(:mapcanv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: MapCanv.PubSub},

# Presence must be after the PubSub child and before the endpoint
# See https://hexdocs.pm/phoenix/Phoenix.Presence.html#module-example-usage
MapCanvWeb.Presence,

# Start the Finch HTTP client for sending emails
{Finch, name: MapCanv.Finch},
# Start a worker by calling: MapCanv.Worker.start_link(arg)
# {MapCanv.Worker, arg},
# Start to serve requests, typically the last entry
MapCanvWeb.Endpoint,
# Start the LinesAgent
MapCanv.FeaturesAgent
# Start the FeaturesAgent
MapCanv.FeaturesAgent,
]

# See https://hexdocs.pm/elixir/Supervisor.html
Expand Down
Loading

0 comments on commit d8b2561

Please sign in to comment.