Skip to content

Commit

Permalink
Merge pull request #224 from jiftechnify/custom-emoji-in-name
Browse files Browse the repository at this point in the history
Support displaying custom emojis in profile
  • Loading branch information
jiftechnify authored Oct 30, 2024
2 parents 45166b3 + 50b19b5 commit 3a5de11
Show file tree
Hide file tree
Showing 12 changed files with 174 additions and 92 deletions.
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Suspense, useRef } from "react";
import { HeaderMenu } from "./components/HeaderMenu";
import { LoginForm } from "./components/LoginForm";
import { UpdateStatusDialog } from "./components/UpdateStatusDialog";
import { UserStatusList, type UserStatusListHandle } from "./components/UserStatusList";
import { UserStatusList, type UserStatusListHandle } from "./components/status/UserStatusList";
import { useMyPubkey, useWriteOpsEnabled } from "./states/nostr";
import { useColorTheme } from "./states/theme";
import { button } from "./styles/recipes";
Expand Down
2 changes: 1 addition & 1 deletion src/components/ShareMusicDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { LangCode } from "../locales/i18n";
import { myMusicStatusAtom, updateMyStatus } from "../states/nostr";
import { button } from "../styles/recipes";
import { useCloseHeaderMenu } from "./HeaderMenu";
import { MusicStatusView } from "./MusicStatusView";
import { MusicStatusView } from "./status/MusicStatusView";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
Expand Down
43 changes: 43 additions & 0 deletions src/components/status/GeneralStatusView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { css } from "@shadow-panda/styled-system/css";
import { type NostrEvent, parseEventContent } from "../../nostr";
import { ExternalLink } from "../ExternalLink";
import { renderEventContentParts } from "./renderEventContentParts";

type GeneralStatusProps = {
srcEvent?: NostrEvent;
linkUrl?: string;
};

export const GeneralStatusView = ({ srcEvent, linkUrl }: GeneralStatusProps) => {
if (srcEvent === undefined) {
return <NoStatus />;
}

const parts = parseEventContent(srcEvent);
return parts.length > 0 ? (
<p
className={css({
textStyle: "main-status",
wordBreak: "break-all",
})}
>
{renderEventContentParts(parts)}
{linkUrl && <ExternalLink href={linkUrl} />}
</p>
) : (
<NoStatus />
);
};

const NoStatus = () => {
return (
<p
className={css({
textStyle: "main-status",
color: "text.no-status",
})}
>
No status
</p>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { css } from "@shadow-panda/styled-system/css";
import { token } from "@shadow-panda/styled-system/tokens";
import { ExternalLink } from "./ExternalLink";
import { ExternalLink } from "../ExternalLink";

type MusicStatusViewProps = {
content: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { css } from "@shadow-panda/styled-system/css";
import { vstack } from "@shadow-panda/styled-system/patterns";
import { nip19 } from "nostr-tools";
import { getFirstTagValueByName } from "../nostr";
import { type StatusData, type UserStatus, type UserStatusCategory, userStatusCategories } from "../states/nostrModels";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { useTranslation } from "react-i18next";
import { getFirstTagValueByName } from "../../nostr";
import {
type StatusData,
type UserStatus,
type UserStatusCategory,
userStatusCategories,
} from "../../states/nostrModels";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";

const tabsListStyle = css({
w: "full",
Expand Down
39 changes: 39 additions & 0 deletions src/components/status/UserProfileView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { css } from "@shadow-panda/styled-system/css";
import { hstack } from "@shadow-panda/styled-system/patterns";
import { type NostrEvent, parseEventContent } from "../../nostr";
import { AppAvatar } from "../AppAvatar";
import { renderEventContentParts } from "./renderEventContentParts";

type UserProfileViewProps = {
srcEvent?: NostrEvent;
picture?: string;
displayName: string;
subName?: string;
};

export const UserProfileView = ({ picture, displayName, subName, srcEvent }: UserProfileViewProps) => {
return (
<div className={hstack({ gap: "1" })}>
<AppAvatar imgSrc={picture} size="sm" />
<div
className={hstack({
gap: "1",
alignItems: "end",
})}
>
<DisplayName {...{ displayName, srcEvent }} />
{subName !== undefined && <SubName {...{ subName, srcEvent }} />}
</div>
</div>
);
};

const DisplayName = ({ displayName, srcEvent }: { displayName: string; srcEvent?: NostrEvent }) => {
const parts = parseEventContent(srcEvent, displayName);
return <p className={css({ textStyle: "display-name" })}>{renderEventContentParts(parts)}</p>;
};

const SubName = ({ subName, srcEvent }: { subName: string; srcEvent?: NostrEvent }) => {
const parts = parseEventContent(srcEvent, subName);
return <p className={css({ textStyle: "sub-name", color: "text.sub" })}>{renderEventContentParts(parts)}</p>;
};
Original file line number Diff line number Diff line change
@@ -1,41 +1,17 @@
import { css } from "@shadow-panda/styled-system/css";
import { circle, hstack, vstack } from "@shadow-panda/styled-system/patterns";
import { circle, vstack } from "@shadow-panda/styled-system/patterns";
import { icon } from "@shadow-panda/styled-system/recipes";
import { useAtomValue } from "jotai";
import { MoreHorizontal } from "lucide-react";
import { useEffect, useState } from "react";
import type { NostrEvent } from "../nostr";
import { eventContentPartKey, parseEventContent } from "../nostr";
import { userProfileAtomFamily, userStatusAtomFamily } from "../states/nostr";
import { type UserProfile, UserStatus } from "../states/nostrModels";
import { currUnixtime } from "../utils";
import { AppAvatar } from "./AppAvatar";
import { CustomEmoji } from "./CustomEmoji";
import { ExternalLink } from "./ExternalLink";
import { userProfileAtomFamily, userStatusAtomFamily } from "../../states/nostr";
import { type UserProfile, UserStatus } from "../../states/nostrModels";
import { currUnixtime } from "../../utils";
import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog";
import { GeneralStatusView } from "./GeneralStatusView";
import { MusicStatusView } from "./MusicStatusView";
import { StatusDetailsView } from "./StatusDetailsView";
import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog";

const extractNames = (profile: UserProfile): { displayName: string; subName: string | undefined } => {
if (!profile.displayName) {
return {
displayName: profile.name || "???",
subName: undefined,
};
}

// profile.displayName is non-empty string
if (profile.name === undefined || profile.displayName.includes(profile.name.trim())) {
return {
displayName: profile.displayName,
subName: undefined,
};
}
return {
displayName: profile.displayName,
subName: profile.name,
};
};
import { UserProfileView } from "./UserProfileView";

type UserStatusCardProps = {
pubkey: string;
Expand Down Expand Up @@ -90,14 +66,14 @@ export const UserStatusCard: React.FC<UserStatusCardProps> = ({ pubkey }) => {
>
<div>
{/* status */}
<GeneralStatus srcEvent={status.general?.srcEvent} linkUrl={status.general?.linkUrl} />
<GeneralStatusView srcEvent={status.general?.srcEvent} linkUrl={status.general?.linkUrl} />

{/* now playing */}
{status.music && <MusicStatusView content={status.music.content} linkUrl={status.music.linkUrl} />}
</div>

{/* profile */}
<div className={hstack({ gap: "1" })}>
{/* <div className={hstack({ gap: "1" })}>
<AppAvatar imgSrc={profile.picture} size="sm" />
<div
className={hstack({
Expand All @@ -108,7 +84,13 @@ export const UserStatusCard: React.FC<UserStatusCardProps> = ({ pubkey }) => {
<p className={css({ textStyle: "display-name" })}>{displayName}</p>
{subName !== undefined && <p className={css({ textStyle: "sub-name", color: "text.sub" })}>{subName}</p>}
</div>
</div>
</div> */}
<UserProfileView
srcEvent={profile.srcEvent}
picture={profile.picture}
displayName={displayName}
subName={subName}
/>

{/* recent update badge */}
{recentlyUpdated && (
Expand Down Expand Up @@ -145,46 +127,25 @@ export const UserStatusCard: React.FC<UserStatusCardProps> = ({ pubkey }) => {
);
};

type GeneralStatusProps = {
srcEvent?: NostrEvent;
linkUrl?: string;
};

const GeneralStatus = ({ srcEvent, linkUrl }: GeneralStatusProps) => {
const parts = parseEventContent(srcEvent);
const extractNames = (profile: UserProfile): { displayName: string; subName: string | undefined } => {
if (!profile.displayName) {
return {
displayName: profile.name || "???",
subName: undefined,
};
}

return parts.length > 0 ? (
<p
className={css({
textStyle: "main-status",
wordBreak: "break-all",
})}
>
{parts.map((part) => {
const key = eventContentPartKey(part);
switch (part.type) {
case "text":
return (
<span key={key} className={css({ verticalAlign: "middle" })}>
{part.text}
</span>
);
case "custom-emoji":
return <CustomEmoji key={key} imgUrl={part.imgUrl} shortcode={part.shortcode} />;
}
})}
{linkUrl && <ExternalLink href={linkUrl} />}
</p>
) : (
<p
className={css({
textStyle: "main-status",
color: "text.no-status",
})}
>
No status
</p>
);
// profile.displayName is non-empty string
if (profile.name === undefined || profile.displayName.includes(profile.name.trim())) {
return {
displayName: profile.displayName,
subName: undefined,
};
}
return {
displayName: profile.displayName,
subName: profile.name,
};
};

const RECENT_UPDATE_THRESHOLD_SEC = 60 * 60; // 1 hour
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { easeInOutQuart } from "js-easing-functions";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next";
import { VList, type VListHandle } from "virtua";
import { pubkeysOrderByLastStatusUpdateTimeAtom } from "../states/nostr";
import { pubkeysOrderByLastStatusUpdateTimeAtom } from "../../states/nostr";
import { UserStatusCard } from "./UserStatusCard";

export type UserStatusListHandle = {
Expand Down
23 changes: 23 additions & 0 deletions src/components/status/renderEventContentParts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { css } from "@shadow-panda/styled-system/css";
import { type EventContentPart, eventContentPartKey } from "../../nostr";
import { CustomEmoji } from "../CustomEmoji";

export const renderEventContentParts = (parts: EventContentPart[]): React.ReactNode[] => {
if (parts.length === 0) {
return [];
}

return parts.map((part) => {
const key = eventContentPartKey(part);
switch (part.type) {
case "text":
return (
<span key={key} className={css({ verticalAlign: "middle" })}>
{part.text}
</span>
);
case "custom-emoji":
return <CustomEmoji key={key} imgUrl={part.imgUrl} shortcode={part.shortcode} />;
}
});
};
19 changes: 15 additions & 4 deletions src/nostr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const getTagsByName = (ev: NostrEvent, name: string): string[][] => ev.ta
export const getTagValuesByName = (ev: NostrEvent, name: string): string[] =>
ev.tags.filter((t) => t[0] === name).map((t) => t[1] ?? "");

/* parsing content */
/* parsing content into parts */
const contentRefPattern = /(:[_a-zA-Z0-9]+:)/;

export type EventContentPart =
Expand All @@ -36,12 +36,23 @@ export const eventContentPartKey = (part: EventContentPart): string => {
}
};

export const parseEventContent = (ev?: NostrEvent): EventContentPart[] => {
if (ev === undefined) {
export const parseEventContent = (ev: NostrEvent | undefined, textToParse?: string): EventContentPart[] => {
const input = textToParse ?? ev?.content ?? "";
if (input.length === 0) {
return [];
}

return ev.content
// parse input "as is" if event is not available
if (ev === undefined) {
return [
{
type: "text",
text: input,
},
];
}

return input
.split(contentRefPattern)
.filter((s) => s !== undefined && s.length > 0)
.map((part) => {
Expand Down
8 changes: 4 additions & 4 deletions src/states/nostr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,11 @@ export const followingsProfilesAtom = atom(new Map<string, UserProfile>());
export const userProfileAtomFamily = atomFamily((pubkey: string) => {
return selectAtom(
followingsProfilesAtom,
(profilesMap) => {
return profilesMap.get(pubkey) ?? { srcEventId: "undefined", pubkey };
(profilesMap): UserProfile => {
return profilesMap.get(pubkey) ?? { pubkey };
},
(a, b) => {
return a.srcEventId === b.srcEventId;
return a.srcEvent !== undefined && b.srcEvent !== undefined && a.srcEvent.id === b.srcEvent.id;
},
);
});
Expand Down Expand Up @@ -317,7 +317,7 @@ export const fetchAccountData = async (pubkey: string): Promise<AccountMetadata>
return fetchBody(defaultBootstrapRelays, true);
}

const profile = k0 !== undefined ? UserProfile.fromEvent(k0) : { srcEventId: "undefined", pubkey };
const profile: UserProfile = k0 !== undefined ? UserProfile.fromEvent(k0) : { pubkey };
const followings = k3 !== undefined ? getTagValuesByName(k3, "p") : [];
const relayList = extractRelayListOrDefault({ k10002, k3 });
return { profile, followings, relayList, lastFetchedAt: currUnixtime() };
Expand Down
Loading

0 comments on commit 3a5de11

Please sign in to comment.