From a90709e44b6480181dfe324a8035b2de928d956d Mon Sep 17 00:00:00 2001 From: lemu Date: Tue, 19 Dec 2023 11:45:46 -0300 Subject: [PATCH] feat: activity ticker (#1455) * chore: events migration and model * chore: latest events route * chore: proposal created event * chore: update created event * chore: proposal update created event wip * chore: remove username from events * chore: report event creation errors * chore: create vote event on voting * chore: fetch all user names and avatars (refactor pending) * feat: show events in home page * chore: catalyst profiles refactor (#1462) * chore: get dcl profile refactor * chore: useProfiles refactor * chore: minor refactors * refactor: remove profile refetch * chore: add todo * refactor: address pr comments * refactor: avatars (#1465) * refactor: remove blockie * refactor: avatar love * refactor: avatar sizes * refactor: remove avatar src * refactor: avatar sizes * feat: improve activity ticker styles * refactor: get profile uses * refactor: remove repeated type * refactor: renames to avatar type enums * feat: update event copies * refactor: move event types to shared folder * refactor: create update service * refactor: update update service * fix: import * feat: refetch events every minute * refactor: address PR comments * refactor: only report errors to rollbar in prod env * refactor: rename * chore: delete old events job * refactor: remove comment * chore: cache users profiles for 1h and shorten addresses for users without name * feat: user address on activity ticker avatar * refactor: address pr comments --------- Co-authored-by: Andy Espagnolo --- src/back/models/Event.ts | 30 ++++ src/back/routes/events.ts | 25 +++ src/back/routes/proposal.ts | 3 +- src/back/routes/update.ts | 82 +++------ src/back/services/discord.ts | 8 +- src/back/services/events.ts | 155 ++++++++++++++++++ src/back/services/update.ts | 97 ++++++++++- src/clients/Governance.ts | 18 ++ .../AddressSelect/AddressesSelect.tsx | 2 +- src/components/Charts/ProposalVPChart.css | 2 +- src/components/Charts/ProposalVPChart.tsx | 12 +- .../Charts/ProposalVPChart.utils.ts | 10 +- src/components/Comments/Comment.tsx | 25 +-- src/components/Comments/Comments.tsx | 2 +- src/components/Common/Avatar.css | 18 +- src/components/Common/Avatar.tsx | 37 ++--- .../ProposalPreviewCard.tsx | 2 +- src/components/Common/Username.css | 13 -- src/components/Common/Username.tsx | 65 ++------ .../Delegation/DelegatesTableRow.tsx | 6 +- .../Delegation/DelegatorCardProfile.tsx | 2 +- .../Delegation/VotingPowerListItem.css | 19 +-- .../Delegation/VotingPowerListItem.tsx | 2 +- src/components/Home/ActivityTicker.css | 73 +++++++++ src/components/Home/ActivityTicker.tsx | 72 ++++++++ src/components/Home/TopVotersRow.tsx | 2 +- .../IdentityConnectModal/PostConnection.tsx | 2 +- src/components/Modal/Votes/VoteListItem.tsx | 2 +- .../VotingPowerDelegationDetail.css | 4 - .../VotingPowerDelegationDetail.tsx | 2 +- .../VotingPowerDelegationCandidatesList.css | 4 - .../Profile/GrantBeneficiaryItem.tsx | 2 +- src/components/Profile/VotedProposalsBox.tsx | 4 +- .../ProjectCard/ProjectCardHeadline.css | 4 - .../ProjectCard/ProjectCardHeadline.tsx | 2 +- src/components/Proposal/ProposalItem.css | 4 - src/components/Proposal/ProposalItem.tsx | 5 +- .../Proposal/View/AuthorDetails.tsx | 10 +- src/components/Proposal/View/ProposalCard.tsx | 4 +- src/components/Transparency/MemberCard.tsx | 2 +- src/components/User/UserAvatar.tsx | 7 +- src/components/User/UserStats.tsx | 2 +- src/entities/Proposal/templates/index.ts | 12 +- src/hooks/constants.ts | 1 + src/hooks/useDclProfile.ts | 27 +++ src/hooks/useDclProfiles.ts | 30 ++++ src/hooks/useProfile.ts | 32 ---- src/hooks/useProfiles.ts | 48 ------ src/intl/en.json | 6 + .../1702322343224_create-events-table.ts | 43 +++++ src/pages/index.css | 10 ++ src/pages/index.tsx | 38 +++-- src/pages/profile.tsx | 6 +- src/pages/proposal.tsx | 6 + src/server.ts | 4 + src/services/DiscourseService.ts | 7 +- src/services/ErrorService.ts | 29 +--- src/services/ProposalService.ts | 4 + src/services/SnapshotService.ts | 7 +- src/shared/types/events.ts | 45 +++++ src/ui-overrides.css | 5 - src/utils/Catalyst/index.ts | 77 ++------- src/utils/Catalyst/types.ts | 11 +- src/utils/errorCategories.ts | 1 + static/api.yaml | 10 ++ 65 files changed, 860 insertions(+), 441 deletions(-) create mode 100644 src/back/models/Event.ts create mode 100644 src/back/routes/events.ts create mode 100644 src/back/services/events.ts create mode 100644 src/components/Home/ActivityTicker.css create mode 100644 src/components/Home/ActivityTicker.tsx create mode 100644 src/hooks/useDclProfile.ts create mode 100644 src/hooks/useDclProfiles.ts delete mode 100644 src/hooks/useProfile.ts delete mode 100644 src/hooks/useProfiles.ts create mode 100644 src/migrations/1702322343224_create-events-table.ts create mode 100644 src/shared/types/events.ts diff --git a/src/back/models/Event.ts b/src/back/models/Event.ts new file mode 100644 index 000000000..e1321721e --- /dev/null +++ b/src/back/models/Event.ts @@ -0,0 +1,30 @@ +import { Model } from 'decentraland-gatsby/dist/entities/Database/model' +import { SQL, table } from 'decentraland-gatsby/dist/entities/Database/utils' + +import { Event } from '../../shared/types/events' + +export default class EventModel extends Model { + static tableName = 'events' + static withTimestamps = false + static primaryKey = 'id' + + static async getLatest(): Promise { + const query = SQL` + SELECT * + FROM ${table(EventModel)} + WHERE created_at >= NOW() - INTERVAL '7 day' + ORDER BY created_at DESC + ` + const result = await this.namedQuery('get_latest_events', query) + return result + } + + static async deleteOldEvents() { + const query = SQL` + DELETE + FROM ${table(EventModel)} + WHERE created_at < NOW() - INTERVAL '7 day' + ` + await this.namedQuery('delete_old_events', query) + } +} diff --git a/src/back/routes/events.ts b/src/back/routes/events.ts new file mode 100644 index 000000000..04f76f3fb --- /dev/null +++ b/src/back/routes/events.ts @@ -0,0 +1,25 @@ +import { WithAuth, auth } from 'decentraland-gatsby/dist/entities/Auth/middleware' +import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle' +import routes from 'decentraland-gatsby/dist/entities/Route/routes' + +import { EventsService } from '../services/events' +import { validateProposalId, validateRequiredString } from '../utils/validations' + +export default routes((route) => { + const withAuth = auth() + route.get('/events', handleAPI(getLatestEvents)) + route.post('/events/voted', withAuth, handleAPI(voted)) +}) + +async function getLatestEvents() { + return await EventsService.getLatest() +} + +async function voted(req: WithAuth) { + const user = req.auth! + + validateProposalId(req.body.proposalId) + validateRequiredString('proposalTitle', req.body.proposalTitle) + validateRequiredString('choice', req.body.choice) + return await EventsService.voted(req.body.proposalId, req.body.proposalTitle, req.body.choice, user) +} diff --git a/src/back/routes/proposal.ts b/src/back/routes/proposal.ts index 1f81c407b..1d526dbd2 100644 --- a/src/back/routes/proposal.ts +++ b/src/back/routes/proposal.ts @@ -333,7 +333,7 @@ export async function createProposalHiring(req: WithAuth) { } try { const profile = await getProfile(configuration.address) - configuration.name = profile?.name + configuration.name = profile.username || configuration.address } catch (error) { ErrorService.report('Error getting profile', { error, @@ -479,6 +479,7 @@ export async function createProposalBid(req: WithAuth) { await BidService.createBid(linked_proposal_id, user, bid) } +/* eslint-disable @typescript-eslint/no-explicit-any */ export async function createProposal(proposalInCreation: ProposalInCreation) { try { return await ProposalService.createProposal(proposalInCreation) diff --git a/src/back/routes/update.ts b/src/back/routes/update.ts index 1b6655dbc..450220ba0 100644 --- a/src/back/routes/update.ts +++ b/src/back/routes/update.ts @@ -21,7 +21,6 @@ import Time from '../../utils/date/Time' import { ErrorCategory } from '../../utils/errorCategories' import { isProdEnv } from '../../utils/governanceEnvs' import { CoauthorService } from '../services/coauthor' -import { DiscordService } from '../services/discord' import { UpdateService } from '../services/update' export default routes((route) => { @@ -103,43 +102,20 @@ async function getProposalUpdateComments(req: Request<{ update_id: string }>) { async function createProposalUpdate(req: WithAuth>) { const { author, health, introduction, highlights, blockers, next_steps, additional_notes } = req.body - const user = req.auth! - const proposalId = req.params.proposal - const proposal = await ProposalModel.findOne({ id: proposalId }) - const isAuthorOrCoauthor = - user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposalId, user))) && author === user - - if (!proposal || !isAuthorOrCoauthor) { - throw new RequestError(`Unauthorized`, RequestError.Forbidden) - } - - const updates = await UpdateModel.find({ - proposal_id: proposalId, - status: UpdateStatus.Pending, - }) - - const currentUpdate = getCurrentUpdate(updates) - const nextPendingUpdate = getNextPendingUpdate(updates) - - if (updates.length > 0 && (currentUpdate || nextPendingUpdate)) { - throw new RequestError(`Updates pending for this proposal`, RequestError.BadRequest) - } - - const data = { - proposal_id: proposal.id, - author, - health, - introduction, - highlights, - blockers, - next_steps, - additional_notes, - } - const update = await UpdateModel.createUpdate(data) - await DiscourseService.createUpdate(update, proposal.title) - DiscordService.newUpdate(proposal.id, proposal.title, update.id, user) - - return update + //TODO: validate update data :) + return await UpdateService.create( + { + proposal_id: req.params.proposal, + author, + health, + introduction, + highlights, + blockers, + next_steps, + additional_notes, + }, + req.auth! + ) } async function updateProposalUpdate(req: WithAuth>) { @@ -151,11 +127,9 @@ async function updateProposalUpdate(req: WithAuth> throw new RequestError(`Update not found: "${id}"`, RequestError.NotFound) } - const { completion_date } = update - const user = req.auth - const proposal = await ProposalModel.findOne({ id: req.params.proposal }) + const proposal = await ProposalModel.findOne({ id: req.params.proposal }) const isAuthorOrCoauthor = user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposalId, user))) && author === user @@ -170,9 +144,8 @@ async function updateProposalUpdate(req: WithAuth> throw new RequestError(`Update is not on time: "${update.id}"`, RequestError.BadRequest) } - const status = !update.due_date || isOnTime ? UpdateStatus.Done : UpdateStatus.Late - - await UpdateModel.update( + return await UpdateService.updateProposalUpdate( + update, { author, health, @@ -181,24 +154,13 @@ async function updateProposalUpdate(req: WithAuth> blockers, next_steps, additional_notes, - status, - completion_date: completion_date || now, - updated_at: now, }, - { id } + id, + proposal, + user!, + now, + isOnTime ) - - const updatedUpdate = await UpdateService.getById(id) - if (updatedUpdate) { - if (!completion_date) { - await DiscourseService.createUpdate(updatedUpdate, proposal.title) - DiscordService.newUpdate(proposal.id, proposal.title, update.id, user) - } else { - UpdateService.commentUpdateEditInDiscourse(updatedUpdate) - } - } - - return true } async function deleteProposalUpdate(req: WithAuth>) { diff --git a/src/back/services/discord.ts b/src/back/services/discord.ts index 87b333e01..c8340d234 100644 --- a/src/back/services/discord.ts +++ b/src/back/services/discord.ts @@ -150,14 +150,10 @@ export class DiscordService { if (user) { try { const profile = await getProfile(user) - const profileHasName = !!profile && profile.hasClaimedName && !!profile.name && profile.name.length > 0 - const displayableUser = profileHasName ? profile.name : user - - const hasAvatar = !!profile && !!profile.avatar embed.setAuthor({ - name: displayableUser, - iconURL: hasAvatar ? profile.avatar.snapshots.face256 : DEFAULT_AVATAR, + name: profile.username || user, + iconURL: profile.avatar, url: getProfileUrl(user), }) } catch (error) { diff --git a/src/back/services/events.ts b/src/back/services/events.ts new file mode 100644 index 000000000..c7cb2b066 --- /dev/null +++ b/src/back/services/events.ts @@ -0,0 +1,155 @@ +import crypto from 'crypto' + +import { addressShortener } from '../../helpers' +import CacheService, { TTL_1_HS } from '../../services/CacheService' +import { ErrorService } from '../../services/ErrorService' +import { + EventType, + EventWithAuthor, + ProposalCreatedEvent, + UpdateCreatedEvent, + VotedEvent, +} from '../../shared/types/events' +import { DEFAULT_AVATAR_IMAGE, getProfiles } from '../../utils/Catalyst' +import { DclProfile } from '../../utils/Catalyst/types' +import { ErrorCategory } from '../../utils/errorCategories' +import EventModel from '../models/Event' + +export class EventsService { + static async getLatest(): Promise { + try { + const latestEvents = await EventModel.getLatest() + const addresses = latestEvents.map((event) => event.address) + + const addressesToProfile = await this.getAddressesToProfiles(addresses) + + const latestEventsWithAuthor: EventWithAuthor[] = [] + for (const event of latestEvents) { + const { address } = event + latestEventsWithAuthor.push({ + author: addressesToProfile[address].username || addressShortener(address), + avatar: addressesToProfile[address].avatar, + ...event, + }) + } + + return latestEventsWithAuthor + } catch (error) { + ErrorService.report('Error fetching events', { error, category: ErrorCategory.Events }) + return [] + } + } + + private static async getAddressesToProfiles(addresses: string[]) { + try { + const profiles = await this.getProfilesWithCache(addresses) + return profiles.reduce((acc, profile) => { + acc[profile.address] = profile + return acc + }, {} as Record) + } catch (error) { + ErrorService.report('Error fetching profiles', { error, category: ErrorCategory.Events }) + return addresses.reduce((acc, address) => { + acc[address] = { address, avatar: DEFAULT_AVATAR_IMAGE, username: null, hasCustomAvatar: false } + return acc + }, {} as Record) + } + } + + static async proposalCreated(proposal_id: string, proposal_title: string, address: string) { + try { + const proposalCreatedEvent: ProposalCreatedEvent = { + id: crypto.randomUUID(), + address, + event_type: EventType.ProposalCreated, + event_data: { proposal_id, proposal_title }, + created_at: new Date(), + } + await EventModel.create(proposalCreatedEvent) + } catch (error) { + this.reportEventError(error as Error, EventType.ProposalCreated, { address, proposal_id, proposal_title }) + } + } + + static async updateCreated(update_id: string, proposal_id: string, proposal_title: string, address: string) { + try { + const updateCreatedEvent: UpdateCreatedEvent = { + id: crypto.randomUUID(), + address, + event_type: EventType.UpdateCreated, + event_data: { update_id, proposal_id, proposal_title }, + created_at: new Date(), + } + await EventModel.create(updateCreatedEvent) + } catch (error) { + this.reportEventError(error as Error, EventType.UpdateCreated, { + address, + update_id, + proposal_id, + proposal_title, + }) + } + } + + static async voted(proposal_id: string, proposal_title: string, choice: string, address: string) { + try { + const votedEvent: VotedEvent = { + id: crypto.randomUUID(), + address, + event_type: EventType.Voted, + event_data: { proposal_id, proposal_title, choice }, + created_at: new Date(), + } + await EventModel.create(votedEvent) + } catch (error) { + this.reportEventError(error as Error, EventType.Voted, { address, proposal_id, proposal_title, choice }) + } + } + + private static reportEventError(error: Error, eventType: EventType, args: Record) { + ErrorService.report('Error creating event', { + error, + event_type: eventType, + ...args, + category: ErrorCategory.Events, + }) + } + + static async deleteOldEvents() { + try { + await EventModel.deleteOldEvents() + } catch (error) { + ErrorService.report('Error deleting old events', { error, category: ErrorCategory.Events }) + } + } + + private static getProfileCacheKey(address: string) { + const cacheKey = `profile-${address.toLowerCase()}` + return cacheKey + } + + static async getProfilesWithCache(addresses: string[]): Promise { + const profiles: DclProfile[] = [] + const addressesToFetch: string[] = [] + + for (const address of addresses) { + const cachedProfile = CacheService.get(this.getProfileCacheKey(address)) + if (cachedProfile) { + profiles.push(cachedProfile) + } else { + addressesToFetch.push(address) + } + } + + if (addressesToFetch.length > 0) { + const dclProfiles: DclProfile[] = await getProfiles(addressesToFetch) + + for (const dclProfile of dclProfiles) { + CacheService.set(this.getProfileCacheKey(dclProfile.address), dclProfile, TTL_1_HS) + profiles.push(dclProfile) + } + } + + return profiles + } +} diff --git a/src/back/services/update.ts b/src/back/services/update.ts index 5e5ed8ae9..4d62c4b84 100644 --- a/src/back/services/update.ts +++ b/src/back/services/update.ts @@ -1,11 +1,18 @@ import logger from 'decentraland-gatsby/dist/entities/Development/logger' +import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import { Discourse, DiscoursePost } from '../../clients/Discourse' +import ProposalModel from '../../entities/Proposal/model' import { ProposalAttributes } from '../../entities/Proposal/types' import UpdateModel from '../../entities/Updates/model' -import { UpdateAttributes } from '../../entities/Updates/types' -import { getUpdateUrl } from '../../entities/Updates/utils' +import { UpdateAttributes, UpdateStatus } from '../../entities/Updates/types' +import { getCurrentUpdate, getNextPendingUpdate, getUpdateUrl } from '../../entities/Updates/utils' import { inBackground } from '../../helpers' +import { DiscourseService } from '../../services/DiscourseService' + +import { CoauthorService } from './coauthor' +import { DiscordService } from './discord' +import { EventsService } from './events' export class UpdateService { static async getById(id: UpdateAttributes['id']) { @@ -55,4 +62,90 @@ export class UpdateService { }) }) } + + static async create(newUpdate: Omit, user: string) { + const { proposal_id, author, health, introduction, highlights, blockers, next_steps, additional_notes } = newUpdate + const proposal = await ProposalModel.findOne({ id: proposal_id }) + const isAuthorOrCoauthor = + user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposal_id, user))) && author === user + + if (!proposal || !isAuthorOrCoauthor) { + throw new RequestError(`Unauthorized`, RequestError.Forbidden) + } + + const updates = await UpdateModel.find({ + proposal_id, + status: UpdateStatus.Pending, + }) + + const currentUpdate = getCurrentUpdate(updates) + const nextPendingUpdate = getNextPendingUpdate(updates) + + if (updates.length > 0 && (currentUpdate || nextPendingUpdate)) { + throw new RequestError(`Updates pending for this proposal`, RequestError.BadRequest) + } + + const data: Omit = + { + proposal_id: proposal.id, + author, + health, + introduction, + highlights, + blockers, + next_steps, + additional_notes, + } + const update = await UpdateModel.createUpdate(data) + await EventsService.updateCreated(update.id, proposal.id, proposal.title, user) + await DiscourseService.createUpdate(update, proposal.title) + DiscordService.newUpdate(proposal.id, proposal.title, update.id, user) + + return update + } + + static async updateProposalUpdate( + update: UpdateAttributes, + newUpdate: Omit< + UpdateAttributes, + 'id' | 'proposal_id' | 'status' | 'completion_date' | 'updated_at' | 'created_at' + >, + id: string, + proposal: ProposalAttributes, + user: string, + now: Date, + isOnTime: boolean + ) { + const status = !update.due_date || isOnTime ? UpdateStatus.Done : UpdateStatus.Late + const { author, health, introduction, highlights, blockers, next_steps, additional_notes } = newUpdate + + await UpdateModel.update( + { + author, + health, + introduction, + highlights, + blockers, + next_steps, + additional_notes, + status, + completion_date: update.completion_date || now, + updated_at: now, + }, + { id } + ) + + const updatedUpdate = await UpdateService.getById(id) + if (updatedUpdate) { + if (!update.completion_date) { + await DiscourseService.createUpdate(updatedUpdate, proposal.title) + await EventsService.updateCreated(update.id, proposal.id, proposal.title, user) + DiscordService.newUpdate(proposal.id, proposal.title, update.id, user) + } else { + UpdateService.commentUpdateEditInDiscourse(updatedUpdate) + } + } + + return true + } } diff --git a/src/clients/Governance.ts b/src/clients/Governance.ts index 2a9092dd2..e89290e94 100644 --- a/src/clients/Governance.ts +++ b/src/clients/Governance.ts @@ -37,6 +37,7 @@ import { Topic } from '../entities/SurveyTopic/types' import { ProjectHealth, UpdateAttributes, UpdateResponse } from '../entities/Updates/types' import { AccountType } from '../entities/User/types' import { Participation, VoteByAddress, VotedProposal, Voter, VotesForProposals } from '../entities/Votes/types' +import { EventWithAuthor } from '../shared/types/events' import { NewsletterSubscriptionResult } from '../shared/types/newsletter' import { PushNotification } from '../shared/types/notifications' import Time from '../utils/date/Time' @@ -739,4 +740,21 @@ export class Governance extends API { ) return response.data } + + async getLatestEvents() { + const response = await this.fetch>(`/events`, this.options().method('GET')) + return response.data + } + + async createVoteEvent(proposalId: string, proposalTitle: string, choice: string) { + const response = await this.fetch>( + `/events/voted`, + this.options().method('POST').authorization({ sign: true }).json({ + proposalId, + proposalTitle, + choice, + }) + ) + return response.data + } } diff --git a/src/components/AddressSelect/AddressesSelect.tsx b/src/components/AddressSelect/AddressesSelect.tsx index 73e969f62..fb449a69f 100644 --- a/src/components/AddressSelect/AddressesSelect.tsx +++ b/src/components/AddressSelect/AddressesSelect.tsx @@ -33,7 +33,7 @@ const components = { } const createUserAddress = (address: string): UserAddress => ({ - label: , + label: , value: address.toLowerCase(), }) diff --git a/src/components/Charts/ProposalVPChart.css b/src/components/Charts/ProposalVPChart.css index 025f79566..d11f01939 100644 --- a/src/components/Charts/ProposalVPChart.css +++ b/src/components/Charts/ProposalVPChart.css @@ -23,7 +23,7 @@ min-height: var(--size) !important; max-height: var(--size) !important; border-radius: 100%; - background-color: var(--black-600); + background-color: var(--black-400); vertical-align: middle; margin: 0; } diff --git a/src/components/Charts/ProposalVPChart.tsx b/src/components/Charts/ProposalVPChart.tsx index c6c531208..cb8744002 100644 --- a/src/components/Charts/ProposalVPChart.tsx +++ b/src/components/Charts/ProposalVPChart.tsx @@ -8,9 +8,9 @@ import annotationPlugin from 'chartjs-plugin-annotation' import { VoteByAddress } from '../../entities/Votes/types' import useAbbreviatedFormatter from '../../hooks/useAbbreviatedFormatter' +import useDclProfiles from '../../hooks/useDclProfiles' import useFormatMessage from '../../hooks/useFormatMessage' -import useProfiles from '../../hooks/useProfiles' -import { Avatar } from '../../utils/Catalyst/types' +import { DclProfile } from '../../utils/Catalyst/types' import Section from '../Proposal/View/Section' import './ProposalVPChart.css' @@ -47,13 +47,13 @@ function ProposalVPChart({ requiredToPass, voteMap, isLoadingVotes, startTimesta const YAxisFormat = useAbbreviatedFormatter() const chartRef = useRef(null) const sortedVotes = useMemo(() => getSortedVotes(voteMap), [voteMap]) - const { profiles, isLoadingProfiles } = useProfiles(sortedVotes.map((vote) => vote.address)) + const { profiles, isLoadingProfiles } = useDclProfiles(sortedVotes.map((vote) => vote.address)) const profileByAddress = useMemo( () => - profiles.reduce((acc, { profile }) => { - acc.set(profile.ethAddress.toLowerCase(), profile) + profiles.reduce((acc, profile) => { + acc.set(profile.address.toLowerCase(), profile) return acc - }, new Map()), + }, new Map()), [profiles] ) const votes = useMemo(() => getSegregatedVotes(sortedVotes, profileByAddress), [profileByAddress, sortedVotes]) diff --git a/src/components/Charts/ProposalVPChart.utils.ts b/src/components/Charts/ProposalVPChart.utils.ts index 9aaefa2d8..619ecc7a8 100644 --- a/src/components/Charts/ProposalVPChart.utils.ts +++ b/src/components/Charts/ProposalVPChart.utils.ts @@ -2,11 +2,11 @@ import type { Chart, ScriptableTooltipContext } from 'chart.js' import { Vote, VoteByAddress } from '../../entities/Votes/types' import { DEFAULT_AVATAR_IMAGE } from '../../utils/Catalyst' -import { Avatar } from '../../utils/Catalyst/types' +import { DclProfile } from '../../utils/Catalyst/types' import Time from '../../utils/date/Time' type VoteWithAddress = Vote & { address: string } -type VoteWithProfile = VoteWithAddress & { profile?: Avatar } +type VoteWithProfile = VoteWithAddress & { profile?: DclProfile } const TOOLTIP_ID = 'ProposalVPChartTooltip' export const HOUR_IN_MS = 60 * 60 * 1000 @@ -18,7 +18,7 @@ export function getSortedVotes(votesMap: VoteByAddress) { .sort((a, b) => a.timestamp - b.timestamp) } -export function getSegregatedVotes(votes: VoteWithAddress[], profileMap: Map) { +export function getSegregatedVotes(votes: VoteWithAddress[], profileMap: Map) { const yes: VoteWithProfile[] = [] const no: VoteWithProfile[] = [] const abstain: VoteWithProfile[] = [] @@ -102,7 +102,7 @@ export function externalTooltipHandler({ context, votes, title }: TooltipHandler const vote = votes[datasetLabel.toLowerCase()]?.[dataIdx - 1] - const username = vote?.profile?.name || vote?.address.slice(0, 7) + const username = vote?.profile?.username || vote?.address.slice(0, 7) const userVP = vote?.vp || 0 const formattedTimestamp = Time(dataPoint?.x || 0).format('DD/MM/YY, HH:mm z') @@ -115,7 +115,7 @@ export function externalTooltipHandler({ context, votes, title }: TooltipHandler //Set Avatar const avatar = document.createElement('img') avatar.className = 'avatar' - avatar.src = vote?.profile?.avatar?.snapshots?.face256 || DEFAULT_AVATAR_IMAGE + avatar.src = vote?.profile?.avatar || DEFAULT_AVATAR_IMAGE // Set Text const textContainer = document.createElement('div') diff --git a/src/components/Comments/Comment.tsx b/src/components/Comments/Comment.tsx index 3875126a1..a4c1ac365 100644 --- a/src/components/Comments/Comment.tsx +++ b/src/components/Comments/Comment.tsx @@ -1,8 +1,7 @@ import DOMPurify from 'dompurify' -import isEthereumAddress from 'validator/lib/isEthereumAddress' import { FORUM_URL } from '../../constants' -import useProfile from '../../hooks/useProfile' +import useDclProfile from '../../hooks/useDclProfile' import Time from '../../utils/date/Time' import locations from '../../utils/locations' import Avatar from '../Common/Avatar' @@ -12,19 +11,20 @@ import ValidatedProfile from '../Icon/ValidatedProfile' import './Comment.css' -function getUserProfileUrl(user: string, address?: string) { +function getDiscourseProfileUrl(user: string, address?: string) { return address ? locations.profile({ address }) : `${FORUM_URL}/u/${user}` } type Props = { - user: string + forumUsername: string avatarUrl: string createdAt: string cooked?: string address?: string } -export default function Comment({ user, avatarUrl, createdAt, cooked, address }: Props) { +/* eslint-disable @typescript-eslint/no-explicit-any */ +export default function Comment({ forumUsername, avatarUrl, createdAt, cooked, address }: Props) { const createMarkup = (html: any) => { DOMPurify.addHook('afterSanitizeAttributes', function (node) { if (node.nodeName && node.nodeName === 'IMG' && node.getAttribute('alt') === 'image') { @@ -33,7 +33,7 @@ export default function Comment({ user, avatarUrl, createdAt, cooked, address }: const hrefAttribute = node.getAttribute('href') if (node.nodeName === 'A' && hrefAttribute?.includes('/u/') && node.className === 'mention') { - const newHref = getUserProfileUrl(hrefAttribute?.split('/u/')[1]) + const newHref = getDiscourseProfileUrl(hrefAttribute?.split('/u/')[1]) node.setAttribute('href', newHref) node.setAttribute('target', '_blank') node.setAttribute('rel', 'noopener noreferrer') @@ -44,8 +44,8 @@ export default function Comment({ user, avatarUrl, createdAt, cooked, address }: return { __html: clean } } - const discourseUserUrl = getUserProfileUrl(user, address) - const { displayableAddress } = useProfile(address) + const discourseUserUrl = getDiscourseProfileUrl(forumUsername, address) + const { username, avatar, hasCustomAvatar, isLoadingDclProfile } = useDclProfile(address) const linkTarget = address ? undefined : '_blank' const linkRel = address ? undefined : 'noopener noreferrer' @@ -53,14 +53,19 @@ export default function Comment({ user, avatarUrl, createdAt, cooked, address }:
- {address ? : } +
- {displayableAddress && !isEthereumAddress(displayableAddress) ? displayableAddress : user} + {username || forumUsername} {address && } diff --git a/src/components/Comments/Comments.tsx b/src/components/Comments/Comments.tsx index 4722b1502..8ab003252 100644 --- a/src/components/Comments/Comments.tsx +++ b/src/components/Comments/Comments.tsx @@ -74,7 +74,7 @@ export default function Comments({ comments, topicId, topicSlug, isLoading, topi - ) : ( - - ) + return } diff --git a/src/components/Common/ProposalPreviewCard/ProposalPreviewCard.tsx b/src/components/Common/ProposalPreviewCard/ProposalPreviewCard.tsx index 514ce3890..2913bc09c 100644 --- a/src/components/Common/ProposalPreviewCard/ProposalPreviewCard.tsx +++ b/src/components/Common/ProposalPreviewCard/ProposalPreviewCard.tsx @@ -59,7 +59,7 @@ const ProposalPreviewCard = ({ proposal, votes, variant, customText, anchor }: P > {variant !== Variant.Slim && ( - + )}
diff --git a/src/components/Common/Username.css b/src/components/Common/Username.css index b8f65d0f8..19dda40e2 100644 --- a/src/components/Common/Username.css +++ b/src/components/Common/Username.css @@ -9,15 +9,6 @@ display: inline; } -.Username .dcl.blockie { - margin: 0; -} - -.Username .dcl.blockie-wrapper { - display: flex; - align-items: center; -} - a.Username span.address { color: inherit; } @@ -27,7 +18,3 @@ a.Username span.address { text-overflow: ellipsis; overflow: hidden; } - -.Username .dcl.blockie-wrapper .dcl.blockie-children { - margin-left: 0.5rem; -} diff --git a/src/components/Common/Username.tsx b/src/components/Common/Username.tsx index 949906818..442feea04 100644 --- a/src/components/Common/Username.tsx +++ b/src/components/Common/Username.tsx @@ -1,9 +1,8 @@ import classNames from 'classnames' import { Address } from 'decentraland-ui/dist/components/Address/Address' -import { Blockie } from 'decentraland-ui/dist/components/Blockie/Blockie' import { getChecksumAddress } from '../../entities/Snapshot/utils' -import useProfile from '../../hooks/useProfile' +import useDclProfile from '../../hooks/useDclProfile' import locations from '../../utils/locations' import Avatar, { AvatarSize } from '../Common/Avatar' import Link from '../Common/Typography/Link' @@ -25,35 +24,15 @@ type Props = { strong?: boolean } -function getBlockieScale(size?: string) { - const DEFAULT_BLOCKIE_SCALE = 3.35 - switch (size) { - case AvatarSize.Mini: - return 3 - case AvatarSize.Tiny: - return DEFAULT_BLOCKIE_SCALE - case AvatarSize.Small: - return 4.9 - case AvatarSize.Medium: - return 7 - case AvatarSize.Large: - return 8.4 - case AvatarSize.Big: - return 10.5 - case AvatarSize.Huge: - return 14.5 - case AvatarSize.Massive: - return 20 - case AvatarSize.Full: - return 42.5 - default: - return DEFAULT_BLOCKIE_SCALE - } -} - -const Username = ({ address, size, linked, variant = UsernameVariant.Full, strong = false, className }: Props) => { - const { hasDclProfile, displayableAddress, profileHasName } = useProfile(address) - const blockieScale = getBlockieScale(size) +const Username = ({ + address, + size = AvatarSize.xs, + linked, + variant = UsernameVariant.Full, + strong = false, + className, +}: Props) => { + const { username, avatar, isLoadingDclProfile } = useDclProfile(address) const isAddressVariant = variant === UsernameVariant.Address const isFullVariant = variant === UsernameVariant.Full const checksumAddress = address ? getChecksumAddress(address) : '' @@ -63,29 +42,13 @@ const Username = ({ address, size, linked, variant = UsernameVariant.Full, stron return ( - {isAddressVariant && ( - <> - {profileHasName && displayableAddress} - {!profileHasName &&
} - - )} + {isAddressVariant && <>{username ||
}} {!isAddressVariant && ( <> - {hasDclProfile && ( - <> - - {profileHasName && isFullVariant && {displayableAddress}} - {!profileHasName && isFullVariant &&
} - - )} - - {!hasDclProfile && ( - <> - - {isFullVariant &&
} - - )} + + {username && isFullVariant && {username}} + {!username && isFullVariant &&
} )} diff --git a/src/components/Delegation/DelegatesTableRow.tsx b/src/components/Delegation/DelegatesTableRow.tsx index d91642624..ba45dc90f 100644 --- a/src/components/Delegation/DelegatesTableRow.tsx +++ b/src/components/Delegation/DelegatesTableRow.tsx @@ -36,11 +36,7 @@ function DelegateRow({ delegate, onDelegateSelected }: Props) { onClick={() => onDelegateSelected(delegate)} > - + diff --git a/src/components/Delegation/DelegatorCardProfile.tsx b/src/components/Delegation/DelegatorCardProfile.tsx index f04760b8f..857e896f1 100644 --- a/src/components/Delegation/DelegatorCardProfile.tsx +++ b/src/components/Delegation/DelegatorCardProfile.tsx @@ -20,7 +20,7 @@ function DelegatorCardProfile({ address, vp }: Props) { return (
- +
diff --git a/src/components/Delegation/VotingPowerListItem.css b/src/components/Delegation/VotingPowerListItem.css index 370dc0b69..ead9e14ed 100644 --- a/src/components/Delegation/VotingPowerListItem.css +++ b/src/components/Delegation/VotingPowerListItem.css @@ -1,17 +1,12 @@ .VotingPowerListItem { - display: flex; - align-items: center; - margin-bottom: 8px; - justify-content: space-between; + display: flex; + align-items: center; + margin-bottom: 8px; + justify-content: space-between; } .VotingPowerListModalItem__Profile { - display: inline-flex; - align-items: center; - padding-bottom: 2px; -} - -.VotingPowerListModalItem__Blockie { - margin-left: 3px !important; - margin-right: 3px; + display: inline-flex; + align-items: center; + padding-bottom: 2px; } diff --git a/src/components/Delegation/VotingPowerListItem.tsx b/src/components/Delegation/VotingPowerListItem.tsx index 7c8610af1..5917f1e02 100644 --- a/src/components/Delegation/VotingPowerListItem.tsx +++ b/src/components/Delegation/VotingPowerListItem.tsx @@ -19,7 +19,7 @@ export default function VotingPowerListItem({ return (
- +
diff --git a/src/components/Home/ActivityTicker.css b/src/components/Home/ActivityTicker.css new file mode 100644 index 000000000..ec7410f02 --- /dev/null +++ b/src/components/Home/ActivityTicker.css @@ -0,0 +1,73 @@ +.ActivityTicker { + width: 100%; + max-width: 377px; + padding: 16px 0; + display: flex; + flex-direction: column; + height: 100vh; + position: sticky; + top: 0; + overflow: hidden; +} + +.ActivityTicker__TitleContainer { + position: relative; + background-color: transparent; +} + +.ActivityTicker__Title { + margin: 0; + color: var(--black-600); + text-transform: uppercase; + margin-bottom: 17px; + position: relative; +} + +.ActivityTicker__Gradient { + position: absolute; + top: 0; + width: 100%; + height: 27px; + background: linear-gradient(180deg, #fff 82.84%, rgba(255, 255, 255, 0) 100%); +} + +.ActivityTicker__LoadingContainer { + position: relative; + top: 100px; +} + +.ActivityTicker__List { + display: flex; + flex-direction: column; + gap: 17px; + overflow: scroll; + overflow-x: hidden; + overscroll-behavior-y: contain; + padding-top: 8px; +} + +.ActivityTicker__List::-webkit-scrollbar { + display: none; +} + +.ActivityTicker__ListItem { + display: flex; + flex-direction: row; + gap: 8px; +} + +.ActivityTicker__ListItemMarkdown { + margin: 0; +} + +.ActivityTicker__ListItemMarkdownTitle { + font-weight: var(--weight-semi-bold) !important; +} + +.ActivityTicker__ListItemDate { + color: var(--black-400) !important; + font-size: 11px; + font-weight: 400; + line-height: 22px; + text-transform: uppercase; +} diff --git a/src/components/Home/ActivityTicker.tsx b/src/components/Home/ActivityTicker.tsx new file mode 100644 index 000000000..aeb553da8 --- /dev/null +++ b/src/components/Home/ActivityTicker.tsx @@ -0,0 +1,72 @@ +import { useQuery } from '@tanstack/react-query' +import { Loader } from 'decentraland-ui/dist/components/Loader/Loader' + +import { Governance } from '../../clients/Governance' +import { ONE_MINUTE_MS } from '../../hooks/constants' +import useFormatMessage from '../../hooks/useFormatMessage' +import Time from '../../utils/date/Time' +import Avatar from '../Common/Avatar' +import Empty from '../Common/Empty' +import Heading from '../Common/Typography/Heading' +import Markdown from '../Common/Typography/Markdown' +import Text from '../Common/Typography/Text' + +import './ActivityTicker.css' + +export default function ActivityTicker() { + const t = useFormatMessage() + const { data: events, isLoading } = useQuery({ + queryKey: ['events'], + queryFn: () => Governance.get().getLatestEvents(), + refetchInterval: ONE_MINUTE_MS, + refetchIntervalInBackground: true, + }) + + return ( +
+
+
+ + {t('page.home.activity_ticker.title')} + +
+ {isLoading && ( +
+ +
+ )} + {!isLoading && ( + <> + {!events && ( +
+ +
+ )} + {events && ( +
+ {events.map((item) => ( +
+ +
+ + {t(`page.home.activity_ticker.${item.event_type}`, { + author: item.author, + title: item.event_data.proposal_title, + })} + + + {Time(item.created_at).fromNow()} + +
+
+ ))} +
+ )} + + )} +
+ ) +} diff --git a/src/components/Home/TopVotersRow.tsx b/src/components/Home/TopVotersRow.tsx index 7de1a333f..dd58879d9 100644 --- a/src/components/Home/TopVotersRow.tsx +++ b/src/components/Home/TopVotersRow.tsx @@ -15,7 +15,7 @@ function TopVotersRow({ address, votes, rank }: Props) {
{rank}
- +
{votes} diff --git a/src/components/Modal/IdentityConnectModal/PostConnection.tsx b/src/components/Modal/IdentityConnectModal/PostConnection.tsx index 4f887234e..002956480 100644 --- a/src/components/Modal/IdentityConnectModal/PostConnection.tsx +++ b/src/components/Modal/IdentityConnectModal/PostConnection.tsx @@ -31,7 +31,7 @@ function PostConnection({ address, isValidated, account, onPostAction }: Props) return (
- + {isValidated ? : } {ACCOUNT_ICON[account]}
diff --git a/src/components/Modal/Votes/VoteListItem.tsx b/src/components/Modal/Votes/VoteListItem.tsx index 8ec09b3ef..a5b7f70a8 100644 --- a/src/components/Modal/Votes/VoteListItem.tsx +++ b/src/components/Modal/Votes/VoteListItem.tsx @@ -29,7 +29,7 @@ export function VoteListItem({ address, vote, choices, isLowQuality, active }: V )} > - +

{formatChoice(choices[vote.choice - 1])}

diff --git a/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.css b/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.css index cd3f03fa6..d52cf18a5 100644 --- a/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.css +++ b/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.css @@ -88,10 +88,6 @@ margin-left: 15px !important; } -.VotingPowerDelegationDetail__Header .dcl.blockie { - margin-right: 6px !important; -} - .VotingPowerDelegationDetail__Header .DelegateButton__Container { margin-right: 55px; } diff --git a/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.tsx b/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.tsx index 354c40044..1687db771 100644 --- a/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.tsx +++ b/src/components/Modal/VotingPowerDelegationDetail/VotingPowerDelegationDetail.tsx @@ -97,7 +97,7 @@ function VotingPowerDelegationDetail({
- +
span svg, .VotingPowerDelegationCandidatesList .ui.table tbody tr:hover { diff --git a/src/components/Profile/GrantBeneficiaryItem.tsx b/src/components/Profile/GrantBeneficiaryItem.tsx index db3d7bb6f..ad9277785 100644 --- a/src/components/Profile/GrantBeneficiaryItem.tsx +++ b/src/components/Profile/GrantBeneficiaryItem.tsx @@ -39,7 +39,7 @@ function GrantBeneficiaryItem({ grant }: Props) {
- +

{title}

{formattedEnactedDate && ( diff --git a/src/components/Profile/VotedProposalsBox.tsx b/src/components/Profile/VotedProposalsBox.tsx index bb7d8b6b7..02ecbc5fe 100644 --- a/src/components/Profile/VotedProposalsBox.tsx +++ b/src/components/Profile/VotedProposalsBox.tsx @@ -25,8 +25,8 @@ function VotedProposalsBox({ address }: Props) { {isLoading && } {!isLoading && (votes.length > 0 ? ( - votes.map((vote) => { - return + votes.map((vote, idx) => { + return }) ) : ( canvas.dcl.blockie { - margin-right: 0 !important; - } - .ProjectCardHeadline__Avatar .Avatar, .Avatar.Avatar--medium { width: 44px; diff --git a/src/components/Projects/ProjectCard/ProjectCardHeadline.tsx b/src/components/Projects/ProjectCard/ProjectCardHeadline.tsx index 0d541edd1..ba20f93d2 100644 --- a/src/components/Projects/ProjectCard/ProjectCardHeadline.tsx +++ b/src/components/Projects/ProjectCard/ProjectCardHeadline.tsx @@ -25,7 +25,7 @@ const ProjectCardHeadline = ({ project, hoverable = false, expanded = false }: P > {title} - +
) } diff --git a/src/components/Proposal/ProposalItem.css b/src/components/Proposal/ProposalItem.css index 0084d357d..4695b019f 100644 --- a/src/components/Proposal/ProposalItem.css +++ b/src/components/Proposal/ProposalItem.css @@ -94,10 +94,6 @@ margin: 0 !important; } -.ProposalItem__Details > span.Username .dcl.blockie { - margin: 0 0 0 0; -} - .ProposalItem__FinishLabel { padding: 0; display: inline-flex; diff --git a/src/components/Proposal/ProposalItem.tsx b/src/components/Proposal/ProposalItem.tsx index 3e26bfad1..9ac29df35 100644 --- a/src/components/Proposal/ProposalItem.tsx +++ b/src/components/Proposal/ProposalItem.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames' import { Card } from 'decentraland-ui/dist/components/Card/Card' -import { Desktop } from 'decentraland-ui/dist/components/Media/Media' +import { Desktop, useTabletAndBelowMediaQuery } from 'decentraland-ui/dist/components/Media/Media' import { ProposalAttributes } from '../../entities/Proposal/types' import { VoteByAddress } from '../../entities/Votes/types' @@ -35,6 +35,7 @@ interface Props { export default function ProposalItem({ proposal, hasCoauthorRequest, votes, slim = false, customText, anchor }: Props) { const t = useFormatMessage() + const isMobile = useTabletAndBelowMediaQuery() const { id, title, status, type, user, start_at, finish_at } = proposal const timeout = useCountdown(finish_at) const isCountdownRunning = timeout.time > 0 @@ -71,7 +72,7 @@ export default function ProposalItem({ proposal, hasCoauthorRequest, votes, slim {customText} ) : ( <> - +
{votes && ( diff --git a/src/components/Proposal/View/AuthorDetails.tsx b/src/components/Proposal/View/AuthorDetails.tsx index 2ee06e10e..84e35e7a0 100644 --- a/src/components/Proposal/View/AuthorDetails.tsx +++ b/src/components/Proposal/View/AuthorDetails.tsx @@ -8,9 +8,9 @@ import upperFirst from 'lodash/upperFirst' import { ProjectStatus } from '../../../entities/Grant/types' import { Project, ProposalStatus, ProposalType } from '../../../entities/Proposal/types' import { CURRENCY_FORMAT_OPTIONS, addressShortener, getEnumDisplayName } from '../../../helpers' +import useDclProfile from '../../../hooks/useDclProfile' import useFormatMessage from '../../../hooks/useFormatMessage' import useGovernanceProfile from '../../../hooks/useGovernanceProfile' -import useProfile from '../../../hooks/useProfile' import useProposals from '../../../hooks/useProposals' import useVestings from '../../../hooks/useVestings' import useVotesByAddress from '../../../hooks/useVotesByAddress' @@ -48,7 +48,7 @@ export default function AuthorDetails({ address }: Props) { const intl = useIntl() const hasPreviouslySubmittedGrants = !!grants && grants?.total > 1 const [isSidebarVisible, setIsSidebarVisible] = useState(false) - const { displayableAddress, profileHasName } = useProfile(address) + const { username } = useDclProfile(address) const { data: vestings } = useVestings(hasPreviouslySubmittedGrants) const projects = useMemo( @@ -99,10 +99,10 @@ export default function AuthorDetails({ address }: Props) {
- +
- +
@@ -162,7 +162,7 @@ export default function AuthorDetails({ address }: Props) {
{showUser && ( <> - + {t('page.home.open_proposals.by_user')} {' · '} @@ -60,7 +60,7 @@ export default function ProposalCard({ )} {showBudget && budget && ( <> - {formatNumber(budget, CURRENCY_FORMAT_OPTIONS as any)} + {formatNumber(budget, CURRENCY_FORMAT_OPTIONS)} {' · '} )} diff --git a/src/components/Transparency/MemberCard.tsx b/src/components/Transparency/MemberCard.tsx index d8f291cf2..93850bf9f 100644 --- a/src/components/Transparency/MemberCard.tsx +++ b/src/components/Transparency/MemberCard.tsx @@ -17,7 +17,7 @@ export default function MemberCard({ member }: Props) { return ( - +
{name}
) diff --git a/src/components/User/UserAvatar.tsx b/src/components/User/UserAvatar.tsx index 3a2d459ca..8e4c1b737 100644 --- a/src/components/User/UserAvatar.tsx +++ b/src/components/User/UserAvatar.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { PreviewCamera, PreviewEmote } from '@dcl/schemas' import { WearablePreview } from 'decentraland-ui/dist/components/WearablePreview/WearablePreview' -import useProfile from '../../hooks/useProfile' +import useDclProfile from '../../hooks/useDclProfile' import './UserAvatar.css' @@ -11,10 +11,11 @@ interface Props { address?: string } +/* eslint-disable @typescript-eslint/no-explicit-any */ export default function UserAvatar({ address }: Props) { const [wearablePreviewController, setWearablePreviewController] = useState() - const { hasDclProfile } = useProfile(address) + const { hasCustomAvatar } = useDclProfile(address) const handleLoad = useCallback(() => { setWearablePreviewController(WearablePreview.createController('wearable-preview')) }, []) @@ -38,7 +39,7 @@ export default function UserAvatar({ address }: Props) { return () => clearInterval(interval) }, [wearablePreviewController]) - if (!address || !hasDclProfile) { + if (!address || !hasCustomAvatar) { return null } diff --git a/src/components/User/UserStats.tsx b/src/components/User/UserStats.tsx index ca7dde844..5367057f6 100644 --- a/src/components/User/UserStats.tsx +++ b/src/components/User/UserStats.tsx @@ -43,7 +43,7 @@ export default function UserStats({ address, vpDistribution, isLoadingVpDistribu
- + {showSettings && }
diff --git a/src/entities/Proposal/templates/index.ts b/src/entities/Proposal/templates/index.ts index 4ac6dc7d7..ef1514d75 100644 --- a/src/entities/Proposal/templates/index.ts +++ b/src/entities/Proposal/templates/index.ts @@ -1,4 +1,4 @@ -import { Avatar } from '../../../utils/Catalyst/types' +import { DclProfile } from '../../../utils/Catalyst/types' import { GrantProposalConfiguration, NewProposalBanName, @@ -27,6 +27,8 @@ import * as poll from './poll' import * as tender from './tender' import { template } from './utils' +/* eslint-disable @typescript-eslint/no-explicit-any */ + type NewConfiguration = | NewProposalLinkedWearables | NewProposalBanName @@ -101,7 +103,7 @@ export type SnapshotTemplateProps = { type: ProposalType configuration: NewConfiguration user: string - profile: Avatar | null + profile: DclProfile | null proposal_url: string } @@ -115,7 +117,7 @@ export const snapshotDescription = async ({ proposal_url, }: SnapshotTemplateProps) => template` -> by ${user + (profile?.name ? ` (${profile.name})` : '')} +> by ${user + (profile?.username ? ` (${profile.username})` : '')} ${ (type === ProposalType.POI ? await poi.pre_description(configuration as any) : '') + @@ -131,7 +133,7 @@ export type ForumTemplate = { type: ProposalType configuration: NewConfiguration user: string - profile: Avatar | null + profile: DclProfile | null proposal_url: string snapshot_url: string snapshot_id: string @@ -149,7 +151,7 @@ export const forumDescription = async ({ snapshot_url, }: ForumTemplate) => template` -> by ${user + (profile?.name ? ` (${profile.name})` : '')} +> by ${user + (profile?.username ? ` (${profile.username})` : '')} ${ (type === ProposalType.POI ? await poi.pre_description(configuration as any) : '') + diff --git a/src/hooks/constants.ts b/src/hooks/constants.ts index 99d992496..13bf334e1 100644 --- a/src/hooks/constants.ts +++ b/src/hooks/constants.ts @@ -1,4 +1,5 @@ export const DEFAULT_QUERY_STALE_TIME = 3.6e6 // 1 hour export const TWENTY_MINUTES_MS = 1.2e6 export const FIVE_MINUTES_MS = 3e5 +export const ONE_MINUTE_MS = 0.6e5 export const ONE_DAY_MS = 8.64e7 diff --git a/src/hooks/useDclProfile.ts b/src/hooks/useDclProfile.ts new file mode 100644 index 000000000..5ca029fab --- /dev/null +++ b/src/hooks/useDclProfile.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query' +import isEthereumAddress from 'validator/lib/isEthereumAddress' + +import { getProfile } from '../utils/Catalyst' + +import { DEFAULT_QUERY_STALE_TIME } from './constants' + +export default function useDclProfile(address?: string | null) { + const fetchProfile = async () => { + if (!address || !isEthereumAddress(address)) return null + + try { + return await getProfile(address) + } catch (error) { + return null + } + } + const { data, isLoading: isLoadingDclProfile } = useQuery({ + queryKey: [`userProfile#${address?.toLowerCase()}`], + queryFn: () => fetchProfile(), + staleTime: DEFAULT_QUERY_STALE_TIME, + }) + + const { username, avatar, hasCustomAvatar } = data || {} + + return { username, avatar, hasCustomAvatar, isLoadingDclProfile } +} diff --git a/src/hooks/useDclProfiles.ts b/src/hooks/useDclProfiles.ts new file mode 100644 index 000000000..b588700d1 --- /dev/null +++ b/src/hooks/useDclProfiles.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import isEthereumAddress from 'validator/lib/isEthereumAddress' + +import { getProfiles } from '../utils/Catalyst' +import { DclProfile } from '../utils/Catalyst/types' + +import { DEFAULT_QUERY_STALE_TIME } from './constants' + +export default function useDclProfiles(addresses: (string | null | undefined)[]): { + profiles: DclProfile[] + isLoadingProfiles: boolean +} { + const fetchProfiles = async () => { + const validAddresses = addresses.filter((address) => isEthereumAddress(address || '')) as string[] + try { + return await getProfiles(validAddresses) + } catch (error) { + console.error(error) + return [] + } + } + + const { data, isLoading: isLoadingProfiles } = useQuery({ + queryKey: [`userProfiles#${addresses.join(',')}`], + queryFn: () => fetchProfiles(), + staleTime: DEFAULT_QUERY_STALE_TIME, + }) + + return { profiles: data || [], isLoadingProfiles } +} diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts deleted file mode 100644 index b13b0ec27..000000000 --- a/src/hooks/useProfile.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import isEthereumAddress from 'validator/lib/isEthereumAddress' - -import { createDefaultAvatar, getProfile } from '../utils/Catalyst' - -import { DEFAULT_QUERY_STALE_TIME } from './constants' - -export default function useProfile(address?: string | null) { - const fetchProfile = async () => { - if (!address || !isEthereumAddress(address)) return null - - try { - const profile = await getProfile(address) - return { profile: profile || createDefaultAvatar(address), isDefaultProfile: !profile } - } catch (error) { - return null - } - } - const { data, isLoading: isLoadingProfile } = useQuery({ - queryKey: [`userProfile#${address?.toLowerCase()}`], - queryFn: () => fetchProfile(), - staleTime: DEFAULT_QUERY_STALE_TIME, - }) - - const { profile, isDefaultProfile } = data || {} - - const hasDclProfile = !!profile && !isDefaultProfile - const profileHasName = hasDclProfile && !!profile.name && profile.name.length > 0 && profile.hasClaimedName - const displayableAddress = profileHasName ? profile.name : address - - return { profile, hasDclProfile, displayableAddress, isLoadingProfile, profileHasName } -} diff --git a/src/hooks/useProfiles.ts b/src/hooks/useProfiles.ts deleted file mode 100644 index 63ee7f1c9..000000000 --- a/src/hooks/useProfiles.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import isEthereumAddress from 'validator/lib/isEthereumAddress' - -import { createDefaultAvatar, getProfiles } from '../utils/Catalyst' -import { Avatar } from '../utils/Catalyst/types' - -import { DEFAULT_QUERY_STALE_TIME } from './constants' - -type Profile = { - profile: Avatar - isDefaultProfile: boolean -} - -export default function useProfiles(addresses: (string | null | undefined)[]): { - profiles: Profile[] - isLoadingProfiles: boolean -} { - const fetchProfiles = async () => { - const validAddresses = addresses.filter((address) => isEthereumAddress(address || '')) as string[] - let validAddressesProfiles: Profile[] = [] - - try { - const profiles = await getProfiles(validAddresses) - validAddressesProfiles = profiles.map((profile, idx) => ({ - profile: profile || createDefaultAvatar(validAddresses[idx]), - isDefaultProfile: !profile, - })) - } catch (error) { - console.error(error) - validAddressesProfiles = validAddresses.map((address) => ({ - profile: createDefaultAvatar(address), - isDefaultProfile: true, - })) - } - - return { profiles: validAddressesProfiles } - } - - const { data, isLoading: isLoadingProfiles } = useQuery({ - queryKey: [`userProfiles#${addresses.join(',')}`], - queryFn: () => fetchProfiles(), - staleTime: DEFAULT_QUERY_STALE_TIME, - }) - - const { profiles } = data || {} - - return { profiles: profiles || [], isLoadingProfiles } -} diff --git a/src/intl/en.json b/src/intl/en.json index 90f1d6dd8..e323dddb8 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -917,6 +917,12 @@ "total_vp": "Total VP", "view_all_delegates": "View all Delegates", "fetching": "Fetching DAO delegates..." + }, + "activity_ticker": { + "title": "Latest activity", + "voted": "**{author}** voted on **{title}**", + "proposal_created": "**{author}** published a new Proposal **{title}**", + "update_created": "**{author}** published an Update on the Project **{title}**" } }, "welcome": { diff --git a/src/migrations/1702322343224_create-events-table.ts b/src/migrations/1702322343224_create-events-table.ts new file mode 100644 index 000000000..a06895486 --- /dev/null +++ b/src/migrations/1702322343224_create-events-table.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" +import EventModel from "../back/models/Event" +import { EventType } from "../shared/types/events" + +export const shorthands: ColumnDefinitions | undefined = undefined + +const EVENT_TYPE = 'event_type' + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createType(EVENT_TYPE, Object.values(EventType)) + const columns: ColumnDefinitions = { + id: { + type: 'TEXT', + primaryKey: true, + notNull: true, + }, + address: { + type: 'TEXT', + notNull: true, + }, + event_type: { + type: EVENT_TYPE, + notNull: true, + }, + event_data: { + type: 'JSONB', + notNull: true, + }, + created_at: { + type: 'TIMESTAMP WITH TIME ZONE', + notNull: true, + default: pgm.func('current_timestamp'), + }, + } + + pgm.createTable(EventModel.tableName, columns) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable(EventModel.tableName) + pgm.dropType(EVENT_TYPE) +} diff --git a/src/pages/index.css b/src/pages/index.css index cc1c72cc4..309e8f4f5 100644 --- a/src/pages/index.css +++ b/src/pages/index.css @@ -1,3 +1,13 @@ html { scroll-behavior: smooth; } + +.HomePage__Container { + display: flex; + flex-direction: row; + gap: 40px; +} + +.HomePage__Content { + width: 100%; +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 8387d5b45..7d0d85b9a 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -3,6 +3,7 @@ import { Loader } from 'decentraland-ui/dist/components/Loader/Loader' import WiderContainer from '../components/Common/WiderContainer' import ActiveCommunityGrants from '../components/Home/ActiveCommunityGrants' +import ActivityTicker from '../components/Home/ActivityTicker' import BottomBanner from '../components/Home/BottomBanner/BottomBanner' import CommunityEngagement from '../components/Home/CommunityEngagement' import DaoDelegates from '../components/Home/DaoDelegates' @@ -10,6 +11,7 @@ import MainBanner from '../components/Home/MainBanner' import MetricsCards from '../components/Home/MetricsCards' import OpenProposals from '../components/Home/OpenProposals' import UpcomingOpportunities from '../components/Home/UpcomingOpportunities' +import { Desktop1200 } from '../components/Layout/Desktop1200' import LoadingView from '../components/Layout/LoadingView' import MaintenanceLayout from '../components/Layout/MaintenanceLayout' import Navigation, { NavigationTab } from '../components/Layout/Navigation' @@ -52,19 +54,29 @@ export default function HomePage() { {!endingSoonProposals && } {endingSoonProposals && ( - - - {isLoadingProposals && } - {!isLoadingProposals && ( - <> - - - - - - - - )} +
+
+ + + {isLoadingProposals && } + {!isLoadingProposals && ( + <> + + + + + + + + )} +
+ + + +
)} diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index d8b49d529..969393e1a 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -16,8 +16,8 @@ import VotedProposalsBox from '../components/Profile/VotedProposalsBox' import VpDelegationBox from '../components/Profile/VpDelegationBox' import VpDelegatorsBox from '../components/Profile/VpDelegatorsBox' import UserStats from '../components/User/UserStats' +import useDclProfile from '../hooks/useDclProfile' import useFormatMessage from '../hooks/useFormatMessage' -import useProfile from '../hooks/useProfile' import useVotingPowerInformation from '../hooks/useVotingPowerInformation' import { navigate } from '../utils/locations' import { isUnderMaintenance } from '../utils/maintenance' @@ -38,7 +38,7 @@ export default function ProfilePage() { navigate(`/profile/?address=${userAddress}`, { replace: true }) } - const { displayableAddress } = useProfile(address) + const { username } = useDclProfile(address) const { delegation, isDelegationLoading, scores, isLoadingScores, vpDistribution, isLoadingVpDistribution } = useVotingPowerInformation(address) @@ -63,7 +63,7 @@ export default function ProfilePage() { return ( <> diff --git a/src/pages/proposal.tsx b/src/pages/proposal.tsx index 5b666d66e..e7be66f81 100644 --- a/src/pages/proposal.tsx +++ b/src/pages/proposal.tsx @@ -227,6 +227,11 @@ export default function ProposalPage() { selectedChoice.choiceIndex!, SurveyEncoder.encode(survey) ) + try { + await Governance.get().createVoteEvent(proposal.id, proposal.title, selectedChoice.choice!) + } catch (e) { + // do nothing + } updatePageState({ changingVote: false, showVotingModal: false, @@ -243,6 +248,7 @@ export default function ProposalPage() { proposal: proposal.id, category: ErrorCategory.Voting, }) + /* eslint-disable @typescript-eslint/no-explicit-any */ if ((error as any).code === ErrorCode.ACTION_REJECTED) { updatePageState({ changingVote: false, diff --git a/src/server.ts b/src/server.ts index 8fa65b435..ab7871006 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,6 +24,7 @@ import coauthor from './back/routes/coauthor' import committee from './back/routes/committee' import common from './back/routes/common' import debug from './back/routes/debug' +import events from './back/routes/events' import newsletter from './back/routes/newsletter' import notification from './back/routes/notification' import project from './back/routes/project' @@ -38,6 +39,7 @@ import users from './back/routes/user' import vestings from './back/routes/vestings' import score from './back/routes/votes' import { DiscordService } from './back/services/discord' +import { EventsService } from './back/services/events' import { updateGovernanceBudgets } from './entities/Budget/jobs' import { activateProposals, finishProposal, publishBids } from './entities/Proposal/jobs' import filesystem, { @@ -62,6 +64,7 @@ jobs.cron('@each10Second', pingSnapshot) jobs.cron('@daily', updateGovernanceBudgets) jobs.cron('@daily', runAirdropJobs) jobs.cron('@monthly', giveTopVoterBadges) +jobs.cron('@daily', EventsService.deleteOldEvents) const file = readFileSync('static/api.yaml', 'utf8') const swaggerDocument = YAML.parse(file) @@ -80,6 +83,7 @@ app.use('/api', [ withBody(), committee, debug, + events, users, proposal, proposalSurveyTopics, diff --git a/src/services/DiscourseService.ts b/src/services/DiscourseService.ts index 6d1e983ac..3fc8c98a2 100644 --- a/src/services/DiscourseService.ts +++ b/src/services/DiscourseService.ts @@ -16,7 +16,7 @@ import { getPublicUpdates, getUpdateUrl } from '../entities/Updates/utils' import UserModel from '../entities/User/model' import { filterComments } from '../entities/User/utils' import { inBackground } from '../helpers' -import { Avatar } from '../utils/Catalyst/types' +import { DclProfile } from '../utils/Catalyst/types' import { ProposalInCreation } from './ProposalService' import { SnapshotService } from './SnapshotService' @@ -25,7 +25,7 @@ export class DiscourseService { static async createProposal( data: ProposalInCreation, proposalId: string, - profile: Avatar | null, + profile: DclProfile | null, snapshotUrl: string, snapshotId: string ) { @@ -34,6 +34,7 @@ export class DiscourseService { const discourseProposal = await Discourse.get().createPost(discoursePost) this.logPostCreation(discourseProposal) return discourseProposal + /* eslint-disable @typescript-eslint/no-explicit-any */ } catch (error: any) { SnapshotService.dropSnapshotProposal(snapshotId) throw new Error(`Forum error: ${error.body?.errors.join(', ')}`, error) @@ -57,7 +58,7 @@ export class DiscourseService { private static async getPost( data: ProposalInCreation, - profile: Avatar | null, + profile: DclProfile | null, proposalId: string, snapshotUrl: string, snapshotId: string diff --git a/src/services/ErrorService.ts b/src/services/ErrorService.ts index 6527f05ab..8a0c87cd7 100644 --- a/src/services/ErrorService.ts +++ b/src/services/ErrorService.ts @@ -1,8 +1,8 @@ import logger from 'decentraland-gatsby/dist/entities/Development/logger' import Rollbar from 'rollbar' -import { DAO_ROLLBAR_TOKEN, GOVERNANCE_API } from '../constants' -import { isHerokuEnv, isLocalEnv, isProdEnv, isStagingEnv } from '../utils/governanceEnvs' +import { DAO_ROLLBAR_TOKEN } from '../constants' +import { isProdEnv } from '../utils/governanceEnvs' const FILTERED_ERRORS = ['FETCH_ERROR', 'ACTION_REJECTED'] @@ -11,7 +11,7 @@ export class ErrorService { accessToken: DAO_ROLLBAR_TOKEN, captureUncaught: true, captureUnhandledRejections: true, - environment: this.getEnvironmentNameForRollbar(), + environment: 'production', itemsPerMinute: 10, maxItems: 50, captureIp: 'anonymize', @@ -25,25 +25,14 @@ export class ErrorService { }, }) - private static getEnvironmentNameForRollbar() { - if (!GOVERNANCE_API || GOVERNANCE_API.length === 0) return 'test' - if (isLocalEnv()) return 'local' - if (isHerokuEnv()) return 'heroku' - if (isStagingEnv()) return 'staging' - return 'production' - } - public static report(errorMsg: string, extraInfo?: Record) { - if (DAO_ROLLBAR_TOKEN) { - this.client.error(errorMsg, { extraInfo }) - } else { - if (isProdEnv()) logger.error('Rollbar server access token not found') + if (isProdEnv()) { + if (DAO_ROLLBAR_TOKEN) { + this.client.error(errorMsg, { extraInfo }) + } else { + logger.error('Rollbar server access token not found') + } } logger.error(errorMsg, extraInfo) } - - public static reportAndThrow(errorMsg: string, data: Record) { - this.report(errorMsg, data) - throw new Error(errorMsg) - } } diff --git a/src/services/ProposalService.ts b/src/services/ProposalService.ts index e832ae871..ca5841ce6 100644 --- a/src/services/ProposalService.ts +++ b/src/services/ProposalService.ts @@ -3,6 +3,7 @@ import { SQLStatement } from 'decentraland-gatsby/dist/entities/Database/utils' import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import { DiscordService } from '../back/services/discord' +import { EventsService } from '../back/services/events' import { NotificationService } from '../back/services/notification' import { DiscoursePost } from '../clients/Discourse' import { SnapshotProposalContent } from '../clients/SnapshotTypes' @@ -87,6 +88,8 @@ export class ProposalService { coAuthors ) + await EventsService.proposalCreated(newProposal.id, newProposal.title, newProposal.user) + DiscordService.newProposal( newProposal.id, title, @@ -146,6 +149,7 @@ export class ProposalService { } } + /* eslint-disable @typescript-eslint/no-explicit-any */ private static async saveToDb( data: ProposalInCreation, id: string, diff --git a/src/services/SnapshotService.ts b/src/services/SnapshotService.ts index 46232e3ab..4cde19c4b 100644 --- a/src/services/SnapshotService.ts +++ b/src/services/SnapshotService.ts @@ -9,7 +9,7 @@ import { proposalUrl, snapshotProposalUrl } from '../entities/Proposal/utils' import { SNAPSHOT_SPACE } from '../entities/Snapshot/constants' import { isSameAddress } from '../entities/Snapshot/utils' import { inBackground } from '../helpers' -import { Avatar } from '../utils/Catalyst/types' +import { DclProfile } from '../utils/Catalyst/types' import { ProposalInCreation, ProposalLifespan } from './ProposalService' import RpcService from './RpcService' @@ -20,7 +20,7 @@ export class SnapshotService { static async createProposal( proposalInCreation: ProposalInCreation, proposalId: string, - profile: Avatar | null, + profile: DclProfile | null, proposalLifespan: ProposalLifespan ) { const blockNumber: number = await RpcService.getBlockNumber() @@ -51,7 +51,7 @@ export class SnapshotService { private static async getProposalTitleAndBody( proposalInCreation: ProposalInCreation, - profile: Avatar | null, + profile: DclProfile | null, proposalId: string ) { const snapshotTemplateProps: templates.SnapshotTemplateProps = { @@ -67,6 +67,7 @@ export class SnapshotService { return { proposalTitle, proposalBody } } + /* eslint-disable @typescript-eslint/no-explicit-any */ private static async getProposalContent(snapshotId: string) { try { return await SnapshotGraphql.get().getProposalContent(snapshotId) diff --git a/src/shared/types/events.ts b/src/shared/types/events.ts new file mode 100644 index 000000000..0b00ea8c2 --- /dev/null +++ b/src/shared/types/events.ts @@ -0,0 +1,45 @@ +export type CommonEventAttributes = { + id: string + address: string + created_at: Date +} + +type VoteEventData = { choice: string } & ProposalEventData +type ProposalEventData = { proposal_id: string; proposal_title: string } +type UpdateCreatedEventData = { + update_id: string +} & ProposalEventData + +export enum EventType { + Voted = 'voted', + ProposalCreated = 'proposal_created', + UpdateCreated = 'update_created', + Commented = 'commented', +} + +export type VotedEvent = { + event_type: EventType.Voted + event_data: VoteEventData +} & CommonEventAttributes + +export type ProposalCreatedEvent = { + event_type: EventType.ProposalCreated + event_data: ProposalEventData +} & CommonEventAttributes + +export type UpdateCreatedEvent = { + event_type: EventType.UpdateCreated + event_data: UpdateCreatedEventData +} & CommonEventAttributes + +export type CommentedEvent = { + event_type: EventType.Commented + event_data: ProposalEventData +} & CommonEventAttributes + +export type Event = VotedEvent | ProposalCreatedEvent | UpdateCreatedEvent | CommentedEvent + +export type EventWithAuthor = { + author: string + avatar: string +} & Event diff --git a/src/ui-overrides.css b/src/ui-overrides.css index d46ba5837..036d177f5 100644 --- a/src/ui-overrides.css +++ b/src/ui-overrides.css @@ -124,11 +124,6 @@ border-radius: 6px; } -.dcl.field.field.address .dcl.blockie { - right: 10px; - top: 14px; -} - .ui.icon.input > i.icon { right: 5px; top: 9px; diff --git a/src/utils/Catalyst/index.ts b/src/utils/Catalyst/index.ts index 6a2268407..353dabf33 100644 --- a/src/utils/Catalyst/index.ts +++ b/src/utils/Catalyst/index.ts @@ -3,22 +3,30 @@ import isEthereumAddress from 'validator/lib/isEthereumAddress' import { isSameAddress } from '../../entities/Snapshot/utils' -import { Avatar, ProfileResponse } from './types' +import { CatalystProfile, DclProfile, ProfileResponse } from './types' const CATALYST_URL = 'https://peer.decentraland.org' export const DEFAULT_AVATAR_IMAGE = 'https://decentraland.org/images/male.png' -export async function getProfile(address: string): Promise { +function getDclProfile(profile: CatalystProfile | null, address: string): DclProfile { + const profileHasName = !!profile && profile.hasClaimedName && !!profile.name && profile.name.length > 0 + const username = profileHasName ? profile.name : null + const hasAvatar = !!profile && !!profile.avatar + const avatar = hasAvatar ? profile.avatar.snapshots.face256 : DEFAULT_AVATAR_IMAGE + return { username, avatar, hasCustomAvatar: hasAvatar, address: address.toLowerCase() } +} + +export async function getProfile(address: string): Promise { if (!isEthereumAddress(address)) { throw new Error(`Invalid address provided. Value: ${address}`) } const response: ProfileResponse = await (await fetch(`${CATALYST_URL}/lambdas/profile/${address}`)).json() - - return response.avatars.length > 0 ? response.avatars[0] : null + const profile = response.avatars.length > 0 ? response.avatars[0] : null + return getDclProfile(profile, address) } -export async function getProfiles(addresses: string[]): Promise<(Avatar | null)[]> { +export async function getProfiles(addresses: string[]): Promise { for (const address of addresses) { if (!isEthereumAddress(address)) { throw new Error(`Invalid address provided. Value: ${address}`) @@ -35,65 +43,12 @@ export async function getProfiles(addresses: string[]): Promise<(Avatar | null)[ }) ).json() - const result: (Avatar | null)[] = [] + const profiles: DclProfile[] = [] for (const address of addresses) { const profile = response.find((profile) => isSameAddress(profile.avatars[0]?.ethAddress, address)) - result.push(profile?.avatars[0] || null) + profiles.push(getDclProfile(profile?.avatars[0] || null, address)) } - return result -} - -export function createDefaultAvatar(address: string): Avatar { - return { - userId: address, - ethAddress: address, - hasClaimedName: false, - avatar: { - snapshots: { - face: DEFAULT_AVATAR_IMAGE, - face128: DEFAULT_AVATAR_IMAGE, - face256: DEFAULT_AVATAR_IMAGE, - body: '', - }, - bodyShape: 'dcl://base-avatars/BaseMale', - eyes: { - color: { - r: 0.125, - g: 0.703125, - b: 0.96484375, - }, - }, - hair: { - color: { - r: 0.234375, - g: 0.12890625, - b: 0.04296875, - }, - }, - skin: { - color: { - r: 0.94921875, - g: 0.76171875, - b: 0.6484375, - }, - }, - wearables: [ - 'dcl://base-avatars/green_hoodie', - 'dcl://base-avatars/brown_pants', - 'dcl://base-avatars/sneakers', - 'dcl://base-avatars/casual_hair_01', - 'dcl://base-avatars/beard', - ], - version: 0, - }, - name: '', - email: '', - description: '', - blocked: [], - inventory: [], - version: 0, - tutorialStep: 0, - } + return profiles } diff --git a/src/utils/Catalyst/types.ts b/src/utils/Catalyst/types.ts index 30b34f4d9..747a6cacb 100644 --- a/src/utils/Catalyst/types.ts +++ b/src/utils/Catalyst/types.ts @@ -1,4 +1,4 @@ -export type Avatar = { +export type CatalystProfile = { userId: string name: string description: string @@ -38,5 +38,12 @@ type Color = { } export type ProfileResponse = { - avatars: Avatar[] + avatars: CatalystProfile[] +} + +export type DclProfile = { + username: string | null + avatar: string + hasCustomAvatar: boolean + address: string } diff --git a/src/utils/errorCategories.ts b/src/utils/errorCategories.ts index cb2ac1c80..42332cb72 100644 --- a/src/utils/errorCategories.ts +++ b/src/utils/errorCategories.ts @@ -3,6 +3,7 @@ export enum ErrorCategory { Bid = 'Bid', Budget = 'Budget', Discourse = 'Discourse', + Events = 'Events', Job = 'Job', Profile = 'Profile', Proposal = 'Proposal', diff --git a/static/api.yaml b/static/api.yaml index 4418115d7..6f0f69d04 100644 --- a/static/api.yaml +++ b/static/api.yaml @@ -15,6 +15,8 @@ tags: description: The DAO Committee section of the API is focused on retrieving information related to the members of the DAO Committee, individuals resnponsible for executing transactions on chain, enacting proposals and with access to the DAO Treasury. - name: Votes description: The Votes section of the API is focused on retrieving information related to the voting process associated with a specific proposal. Votes are the way community members express their support or opposition to a proposal. + - name: Events + description: The Events section of the API is focused on retrieving information related to the user actions regarding proposals creation, proposal updates, and comments. - name: Subscriptions (User Watchlist) description: The Subscriptions section of the API is focused on retrieving information related to the subscriptions associated with a specific proposal. This feature is called Watchlist on the Governance dApp. Watchlists are usually used to have a closer look on updates or changes related to a proposal. - name: Badges @@ -255,6 +257,14 @@ paths: responses: '200': $ref: '#/components/responses/200' + /events: + get: + tags: + - Events + summary: Get the latest user actions on proposals creation, proposal updates, and comments + responses: + '200': + $ref: '#/components/responses/200' /proposals/{proposal}/votes: get: tags: