Render custom emojis in display names
This commit is contained in:
parent
0b99590069
commit
8321999d2b
16 changed files with 118 additions and 42 deletions
|
@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Render custom emojis in display names.
|
||||
|
||||
### Changed
|
||||
|
||||
- Use `/@username` routes by default.
|
||||
|
|
17
src/api/emojis.ts
Normal file
17
src/api/emojis.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
export interface CustomEmoji {
|
||||
shortcode: string,
|
||||
url: string,
|
||||
}
|
||||
|
||||
export function replaceShortcodes(text: string, emojis: CustomEmoji[]): string {
|
||||
return text.replace(/:([\w.]+):/g, (match, shortcode) => {
|
||||
const emoji = emojis.find((emoji) => {
|
||||
return emoji.shortcode === shortcode
|
||||
})
|
||||
if (emoji) {
|
||||
return `<img class="emoji" title=":${emoji.shortcode}:" alt=":${emoji.shortcode}:" src="${emoji.url}">`
|
||||
} else {
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
import { PAGE_SIZE, http } from "./common"
|
||||
import { CustomEmoji } from "./emojis"
|
||||
import { defaultProfile, Profile } from "./users"
|
||||
|
||||
export interface Attachment {
|
||||
|
@ -52,11 +53,6 @@ export interface Tag {
|
|||
url: string;
|
||||
}
|
||||
|
||||
export interface CustomEmoji {
|
||||
shortcode: string,
|
||||
url: string,
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
uri: string;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { RouteLocationRaw } from "vue-router"
|
|||
import { BACKEND_URL } from "@/constants"
|
||||
import { createDidFromEthereumAddress } from "@/utils/did"
|
||||
import { PAGE_SIZE, http } from "./common"
|
||||
import { CustomEmoji } from "./emojis"
|
||||
|
||||
export const EXTRA_FIELD_COUNT_MAX = 10
|
||||
|
||||
|
@ -48,6 +49,7 @@ export interface Profile {
|
|||
identity_proofs: ProfileField[];
|
||||
payment_options: ProfilePaymentOption[];
|
||||
fields: ProfileField[];
|
||||
emojis: CustomEmoji[],
|
||||
|
||||
followers_count: number;
|
||||
following_count: number;
|
||||
|
@ -69,6 +71,7 @@ export function defaultProfile(): Profile {
|
|||
identity_proofs: [],
|
||||
payment_options: [],
|
||||
fields: [],
|
||||
emojis: [],
|
||||
followers_count: 0,
|
||||
following_count: 0,
|
||||
subscribers_count: 0,
|
||||
|
|
|
@ -15,12 +15,12 @@
|
|||
<avatar :profile="post.account"></avatar>
|
||||
</a>
|
||||
<a
|
||||
class="display-name"
|
||||
class="display-name-link"
|
||||
:href="post.account.url"
|
||||
:title="author.getDisplayName()"
|
||||
@click="openProfile($event, post.account)"
|
||||
>
|
||||
{{ author.getDisplayName() }}
|
||||
<profile-display-name :profile="author"></profile-display-name>
|
||||
</a>
|
||||
<div class="actor-address" :title="'@' + getActorAddress(post.account)">
|
||||
@{{ getActorAddress(post.account) }}
|
||||
|
@ -80,9 +80,8 @@
|
|||
>
|
||||
<div class="quote-header">
|
||||
<avatar :profile="linkedPost.account"></avatar>
|
||||
<span class="display-name">
|
||||
{{ getQuoteAuthorDisplayName(linkedPost) }}
|
||||
</span>
|
||||
<profile-display-name :profile="getQuoteAuthor(linkedPost)">
|
||||
</profile-display-name>
|
||||
<span class="actor-address">
|
||||
@{{ getActorAddress(linkedPost.account) }}
|
||||
</span>
|
||||
|
@ -270,6 +269,7 @@ import CryptoAddress from "@/components/CryptoAddress.vue"
|
|||
import PostAttachment from "@/components/PostAttachment.vue"
|
||||
import PostContent from "@/components/PostContent.vue"
|
||||
import PostEditor from "@/components/PostEditor.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import VisibilityIcon from "@/components/VisibilityIcon.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
@ -351,9 +351,8 @@ function getReplyMentions(): Mention[] {
|
|||
return props.post.mentions
|
||||
}
|
||||
|
||||
function getQuoteAuthorDisplayName(post: Post): string | null {
|
||||
const profile = new ProfileWrapper(post.account)
|
||||
return profile.getDisplayName()
|
||||
function getQuoteAuthor(post: Post): ProfileWrapper {
|
||||
return new ProfileWrapper(post.account)
|
||||
}
|
||||
|
||||
function canReply(): boolean {
|
||||
|
@ -607,7 +606,7 @@ async function onMintToken() {
|
|||
}
|
||||
}
|
||||
|
||||
.display-name {
|
||||
.display-name-link {
|
||||
color: $text-color;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { onMounted } from "vue"
|
|||
import { $, $ref } from "vue/macros"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
import { replaceShortcodes } from "@/api/emojis"
|
||||
import { Post } from "@/api/posts"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { addGreentext } from "@/utils/greentext"
|
||||
|
@ -72,16 +73,7 @@ function configureInlineLinks() {
|
|||
function getContent(): string {
|
||||
let content = addGreentext(props.post.content)
|
||||
// Replace emoji shortcodes
|
||||
content = content.replace(/:([\w.]+):/g, (match, shortcode) => {
|
||||
const emoji = props.post.emojis.find((emoji) => {
|
||||
return emoji.shortcode === shortcode
|
||||
})
|
||||
if (emoji) {
|
||||
return `<img class="emoji" title=":${emoji.shortcode}:" alt=":${emoji.shortcode}:" src="${emoji.url}">`
|
||||
} else {
|
||||
return match
|
||||
}
|
||||
})
|
||||
content = replaceShortcodes(content, props.post.emojis)
|
||||
return content
|
||||
}
|
||||
</script>
|
||||
|
@ -92,6 +84,8 @@ function getContent(): string {
|
|||
@import "../styles/mixins";
|
||||
|
||||
.post-content {
|
||||
@include ugc-emoji;
|
||||
|
||||
color: $text-color;
|
||||
line-height: 1.5;
|
||||
padding: $block-inner-padding;
|
||||
|
@ -168,10 +162,6 @@ function getContent(): string {
|
|||
}
|
||||
|
||||
:deep(.emoji) {
|
||||
height: 24px;
|
||||
vertical-align: text-bottom;
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
height: 48px;
|
||||
transition: 100ms linear;
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
<router-link
|
||||
:to="{ name: 'profile-by-acct', params: { acct: post.account.acct }}"
|
||||
:title="getActorAddress(post.account)"
|
||||
class="display-name-link"
|
||||
>
|
||||
{{ author.getDisplayName() }}
|
||||
<profile-display-name :profile="author"></profile-display-name>
|
||||
</router-link>
|
||||
<span>reposted</span>
|
||||
</div>
|
||||
|
@ -32,6 +33,7 @@ import { $, $computed } from "vue/macros"
|
|||
import type { Post as PostObject } from "@/api/posts"
|
||||
import { ProfileWrapper } from "@/api/users"
|
||||
import Post from "@/components/Post.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
|
||||
/* eslint-disable-next-line no-undef */
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<div class="avatar-row">
|
||||
<avatar :profile="profile"></avatar>
|
||||
<div class="name-group">
|
||||
<div class="display-name">{{ profile.getDisplayName() }}</div>
|
||||
<profile-display-name :profile="profile"></profile-display-name>
|
||||
<div class="actor-address">@{{ getActorAddress(profile) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -27,6 +27,7 @@ import { $computed } from "vue/macros"
|
|||
|
||||
import { Profile, ProfileWrapper } from "@/api/users"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
|
||||
/* eslint-disable-next-line no-undef */
|
||||
|
|
32
src/components/ProfileDisplayName.vue
Normal file
32
src/components/ProfileDisplayName.vue
Normal file
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<span
|
||||
class="display-name"
|
||||
v-html="getDisplayNameHtml()"
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { replaceShortcodes } from "@/api/emojis"
|
||||
import { ProfileWrapper } from "@/api/users"
|
||||
|
||||
/* eslint-disable-next-line no-undef */
|
||||
const props = defineProps<{
|
||||
profile: ProfileWrapper,
|
||||
}>()
|
||||
|
||||
function getDisplayNameHtml(): string {
|
||||
const profile = props.profile
|
||||
const escaped = new Option(profile.getDisplayName()).innerHTML
|
||||
return replaceShortcodes(escaped, profile.emojis)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../styles/mixins";
|
||||
@import "../styles/theme";
|
||||
|
||||
.display-name {
|
||||
@include ugc-emoji;
|
||||
}
|
||||
</style>
|
|
@ -2,7 +2,7 @@
|
|||
<div class="profile">
|
||||
<avatar :profile="profile"></avatar>
|
||||
<div class="name">
|
||||
<div class="display-name">{{ profile.getDisplayName() }}</div>
|
||||
<profile-display-name :profile="profile"></profile-display-name>
|
||||
<div class="actor-address">@{{ getActorAddress(profile) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -13,6 +13,7 @@ import { $computed } from "vue/macros"
|
|||
|
||||
import { Profile, ProfileWrapper } from "@/api/users"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
|
||||
const { getActorAddress } = useInstanceInfo()
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:to="{ name: 'profile-by-acct', params: { acct: sender.acct }}"
|
||||
>
|
||||
<avatar :profile="sender"></avatar>
|
||||
<div class="display-name">{{ sender.getDisplayName() }}</div>
|
||||
<profile-display-name :profile="sender"></profile-display-name>
|
||||
<div class="wallet-address">{{ walletAddress ? walletAddress.toLowerCase() : '?' }}</div>
|
||||
</component>
|
||||
<div class="separator">
|
||||
|
@ -18,7 +18,7 @@
|
|||
:to="{ name: 'profile-by-acct', params: { acct: recipient.acct }}"
|
||||
>
|
||||
<avatar :profile="recipient"></avatar>
|
||||
<div class="display-name">{{ recipient.getDisplayName() }}</div>
|
||||
<profile-display-name :profile="recipient"></profile-display-name>
|
||||
<div class="wallet-address">{{ recipientEthereumAddress }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
@ -125,6 +125,7 @@ import {
|
|||
} from "@/api/subscriptions-ethereum"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import Loader from "@/components/Loader.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import { useWallet } from "@/composables/wallet"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:to="{ name: 'profile-by-acct', params: { acct: sender.acct } }"
|
||||
>
|
||||
<avatar :profile="sender"></avatar>
|
||||
<div class="display-name">{{ sender.getDisplayName() }}</div>
|
||||
<profile-display-name :profile="sender"></profile-display-name>
|
||||
</component>
|
||||
<div class="separator">
|
||||
<img :src="require('@/assets/feather/arrow-right.svg')">
|
||||
|
@ -17,7 +17,7 @@
|
|||
:to="{ name: 'profile-by-acct', params: { acct: recipient.acct }}"
|
||||
>
|
||||
<avatar :profile="recipient"></avatar>
|
||||
<div class="display-name">{{ recipient.getDisplayName() }}</div>
|
||||
<profile-display-name :profile="recipient"></profile-display-name>
|
||||
</router-link>
|
||||
</div>
|
||||
<form class="sender" v-if="sender.id === ''">
|
||||
|
@ -131,6 +131,7 @@ import { defaultProfile, Profile, ProfilePaymentOption, ProfileWrapper } from "@
|
|||
import Avatar from "@/components/Avatar.vue"
|
||||
import Loader from "@/components/Loader.vue"
|
||||
import QrCode from "@/components/QrCode.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { formatDate } from "@/utils/dates"
|
||||
import { createMoneroPaymentUri } from "@/utils/monero"
|
||||
|
|
|
@ -158,3 +158,11 @@
|
|||
color: $error-color;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin ugc-emoji {
|
||||
:deep(.emoji) {
|
||||
height: 24px;
|
||||
vertical-align: text-bottom;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,8 +20,10 @@
|
|||
<router-link
|
||||
:title="getSenderInfo(notification)"
|
||||
:to="{ name: 'profile-by-acct', params: { acct: notification.account.acct } }"
|
||||
class="display-name-link"
|
||||
>
|
||||
{{ getSenderName(notification) }}
|
||||
<profile-display-name :profile="getSender(notification)">
|
||||
</profile-display-name>
|
||||
</router-link>
|
||||
<span v-if="notification.type === 'follow'">followed you</span>
|
||||
<span v-else-if="notification.type === 'reply'">replied to your post</span>
|
||||
|
@ -47,7 +49,8 @@
|
|||
<div class="floating-avatar">
|
||||
<avatar :profile="notification.account"></avatar>
|
||||
</div>
|
||||
<div class="display-name">{{ getSenderName(notification) }}</div>
|
||||
<profile-display-name :profile="getSender(notification)">
|
||||
</profile-display-name>
|
||||
<div class="actor-address">@{{ getActorAddress(notification.account) }}</div>
|
||||
<div class="timestamp">{{ humanizeDate(notification.created_at) }}</div>
|
||||
</router-link>
|
||||
|
@ -73,6 +76,7 @@ import { getNotifications, Notification } from "@/api/notifications"
|
|||
import { ProfileWrapper } from "@/api/users"
|
||||
import Avatar from "@/components/Avatar.vue"
|
||||
import Post from "@/components/Post.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import SidebarLayout from "@/components/SidebarLayout.vue"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
import { useNotifications } from "@/store/notifications"
|
||||
|
@ -94,13 +98,13 @@ onMounted(async () => {
|
|||
}
|
||||
})
|
||||
|
||||
function getSenderName(notification: Notification): string {
|
||||
const sender = new ProfileWrapper(notification.account)
|
||||
return sender.getDisplayName()
|
||||
function getSender(notification: Notification): ProfileWrapper {
|
||||
return new ProfileWrapper(notification.account)
|
||||
}
|
||||
|
||||
function getSenderInfo(notification: Notification): string {
|
||||
return `${getSenderName(notification)} (${getActorAddress(notification.account)})`
|
||||
const senderName = getSender(notification).getDisplayName()
|
||||
return `${senderName} (${getActorAddress(notification.account)})`
|
||||
}
|
||||
|
||||
function onPostDeleted(notificationIndex: number) {
|
||||
|
|
|
@ -105,7 +105,8 @@
|
|||
</div>
|
||||
<div class="name-buttons-group">
|
||||
<div class="name-group">
|
||||
<div class="display-name">{{ profile.getDisplayName() }}</div>
|
||||
<profile-display-name :profile="profile">
|
||||
</profile-display-name>
|
||||
<div class="actor-address">@{{ actorAddress }}</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
|
@ -280,6 +281,7 @@ import {
|
|||
import Avatar from "@/components/Avatar.vue"
|
||||
import Loader from "@/components/Loader.vue"
|
||||
import PostList from "@/components/PostList.vue"
|
||||
import ProfileDisplayName from "@/components/ProfileDisplayName.vue"
|
||||
import ProfileListItem from "@/components/ProfileListItem.vue"
|
||||
import SidebarLayout from "@/components/SidebarLayout.vue"
|
||||
import { useEthereumAddressVerification } from "@/composables/ethereum-address-verification"
|
||||
|
|
15
tests/unit/profile-wrapper.spec.ts
Normal file
15
tests/unit/profile-wrapper.spec.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { expect } from "chai"
|
||||
import { defaultProfile, ProfileWrapper } from "@/api/users"
|
||||
|
||||
describe("ProfileWrapper", () => {
|
||||
it("Replace invisible characters", () => {
|
||||
const hidden = ""
|
||||
expect(hidden.length).to.equal(1)
|
||||
const profile = new ProfileWrapper({
|
||||
...defaultProfile(),
|
||||
username: "test",
|
||||
display_name: hidden,
|
||||
})
|
||||
expect(profile.getDisplayName()).to.equal("test")
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue