Skip to content

Instantly share code, notes, and snippets.

@ugened47
Last active March 5, 2025 16:09
Show Gist options
  • Save ugened47/3e356e2c8c7a11bc2e1ce9280c071f6c to your computer and use it in GitHub Desktop.
Save ugened47/3e356e2c8c7a11bc2e1ce9280c071f6c to your computer and use it in GitHub Desktop.
Code examples from old project with Nest.js
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillsModule } from './bills/bills.module';
import { AdminModule } from '@adminjs/nestjs';
import AdminJS from 'adminjs';
import { Database, Resource } from '@adminjs/typeorm';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { LoggerModule, PinoLogger } from 'nestjs-pino';
import { PinoTypeormLogger } from './common/utils/pino.typeorm.logger';
import { ProposalsModule } from './proposals/proposals.module';
import appConfig from './config/app.config';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { GroupsModule } from './groups/groups.module';
import { RolesModule } from './roles/roles.module';
import { getConnectionOptions } from 'typeorm';
import { adminResources } from './admin.options';
import { TeamsModule } from './teams/teams.module';
import { LikesModule } from './likes/likes.module';
import { InvitationsModule } from './invitations/invitations.module';
import { ScheduleModule } from '@nestjs/schedule';
import { User } from './users/entities/user.entity';
import * as bcrypt from 'bcrypt';
import { Role as RoleEnum } from 'src/auth/role.enum';
import { MailCronService } from './cron/mail.cron';
import { newBillMailing } from './jobs/newBillMailing.job';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
AdminJS.registerAdapter({ Database, Resource });
@Module({
imports: [
ScheduleModule.forRoot(),
ConfigModule.forRoot({
envFilePath: `.env.${process.env.NODE_ENV}`,
ignoreEnvFile: process.env.NODE_ENV === 'production',
load: [appConfig],
isGlobal: true,
}),
LoggerModule.forRoot({
pinoHttp: {
level: process.env.NODE_ENV !== 'production' ? 'debug' : 'info',
prettyPrint: process.env.NODE_ENV !== 'production',
},
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'),
exclude: ['/api*'],
}),
TypeOrmModule.forRootAsync({
useFactory: async (logger: PinoLogger) =>
Object.assign(await getConnectionOptions(), {
autoLoadEntities: true,
logging: true,
logger: new PinoTypeormLogger(logger),
}),
inject: [PinoLogger],
}),
AdminModule.createAdmin({
adminJsOptions: {
rootPath: '/admin',
branding: {
companyName: 'OpenSaeima',
softwareBrothers: false,
logo: false,
},
resources: adminResources,
},
auth: {
authenticate: async (email, password) => {
const adminResolver = adminResources.find(
(el) => el.resource === User,
);
if (adminResolver.resource === User) {
const admin = await adminResolver.resource.findOne({
email: email,
});
let adminRole = null;
let isPasswordMatching = false;
if (admin) {
adminRole = admin?.roles.find(
(role) => role.value === RoleEnum.SystemAdmin,
);
isPasswordMatching = await bcrypt.compare(
password,
admin.password,
);
}
if (
adminRole === null ||
adminRole === undefined ||
isPasswordMatching === false
) {
return Promise.resolve(null);
}
return Promise.resolve({ email: email });
}
return Promise.resolve(null);
},
cookieName: 'sessionCookie',
cookiePassword: String(process.env.COOKIE_SECRET),
},
}),
AuthModule,
BillsModule,
ProposalsModule,
UsersModule,
GroupsModule,
RolesModule,
TeamsModule,
LikesModule,
InvitationsModule,
SubscriptionsModule,
],
controllers: [AppController],
providers: [AppService, MailCronService, newBillMailing],
})
export class AppModule {}
import { IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateProposalDto {
@ApiProperty()
@IsString()
@IsUUID()
readonly uuid: string;
@ApiProperty()
@IsOptional()
@IsString()
readonly content?: string;
@ApiProperty()
@IsString()
readonly refNodeJson: string;
@ApiProperty()
@IsOptional()
@IsString()
readonly contentJson?: string;
@ApiProperty()
@IsNumber()
readonly documentId: number;
@ApiProperty()
@IsNumber()
readonly teamId: number;
}
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Alert, Platform } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import convertToDraftJSON from 'plain-text-to-draftjs';
import { replaceMentionValues } from 'react-native-controlled-mentions';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { EmojiType } from 'rn-emoji-keyboard/lib/typescript/src/types';
import { SearchUserResult } from '@authencity/api/src';
import {
GradientButton,
Paragraph,
styled,
XStack,
YStack,
Spinner,
CustomTabs,
} from '@authencity/ui';
import { AttachmentList } from 'app/components/Attachments';
import { AttachmentsActionBar } from 'app/components/AttachmentsActionBar';
import Container from 'app/components/Container';
import { AuthCurrency, CreatePostFileTypeEnum } from 'app/constants/app';
import useAuth from 'app/context/auth';
import { HomeStackScreenProps } from 'app/types/navigation';
import { ISendPublication } from 'app/types/user';
import NewThreadItem from './NewThreadItem';
import useCreatePost from './hooks/useCreatePost';
const TextLengthBedge = styled(XStack, {
name: 'TextLengthBedge',
bg: '$background',
alignSelf: 'flex-start',
px: '$2',
borderRadius: '$true',
mx: 12,
});
const ATTACHMENT_MIME_TYPE = {
[CreatePostFileTypeEnum.image]: ['image/jpeg', 'image/png'],
[CreatePostFileTypeEnum.image360]: ['image/jpeg', 'image/png'],
[CreatePostFileTypeEnum.video]: ['video/mp4', 'video/webm'],
[CreatePostFileTypeEnum.gif]: ['image/gif'],
[CreatePostFileTypeEnum.audio]: ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'],
[CreatePostFileTypeEnum.image3D]: ['model/gltf-binary', 'model/gltf+json'],
};
interface IAttachment {
uri: string;
name: string;
type: string;
attachmentType: CreatePostFileTypeEnum;
}
interface IThreadItem extends Omit<ISendPublication, 'IpfsFiles'> {
IpfsFiles: IAttachment[];
Mentions: SearchUserResult[];
Count: number;
}
type OnTextChangeArgs = {
value: string;
mentions: SearchUserResult[];
};
enum PublicationType {
blockchain = 'Publication',
encrypted = 'Encrypted_publication',
superpost = 'Superpost',
blogpost = 'Blogpost',
}
const INITIAL_THREAD_ITEMS: IThreadItem[] = [
{
Id: '0',
EditorData: {
blocks: [],
entityMap: {},
},
IpfsFiles: [],
Text: '',
Mentions: [],
Count: 0,
},
];
export default function NewPublicationScreen({
navigation,
}: HomeStackScreenProps<'NewPublicationModal'>) {
const [threadItems, _setThreadItems] = useState<IThreadItem[]>(INITIAL_THREAD_ITEMS);
const threadItemsRef = useRef(threadItems);
const setThreadItems = (value: IThreadItem[]) => {
threadItemsRef.current = value;
_setThreadItems(value);
};
const [activeThreadItemId, setActiveThreadItemId] = useState<string>('0');
const [publicationType, setPublicationType] = useState<PublicationType>(
PublicationType.blockchain
);
const { handleMint, isMinting, publication } = useCreatePost(publicationType);
const [currency, setCurrency] = useState<AuthCurrency>(AuthCurrency.ethereum);
const { currentUser } = useAuth();
const submitDisabled = useMemo(() => {
return threadItems.some((item) => {
return !item?.Text && item?.IpfsFiles?.length === 0;
});
}, [threadItems]);
useEffect(() => {
// Use `setOptions` to update the button that we previously specified
// Now the button includes an `onPress` handler to update the count
navigation.setOptions({
headerRight: () => (
<GradientButton
disabled={submitDisabled || isMinting}
w={74}
h={32}
fontSize="$2"
onPress={handleMintPress}
>
{isMinting ? <Spinner color="$white" /> : 'Mint'}
</GradientButton>
),
});
}, [navigation, threadItems, currency, isMinting, submitDisabled]);
const hasUnsavedChanges = threadItems.some(
(item) => item.Text!.length > 0 || item.IpfsFiles.length > 0
);
useEffect(
() =>
navigation.addListener('beforeRemove', (e) => {
if (!hasUnsavedChanges || !!publication) {
// If we don't have unsaved changes, then we don't need to do anything
return;
}
// Prevent default behavior of leaving the screen
e.preventDefault();
// Prompt the user before leaving the screen
Alert.alert(
'Discard changes?',
'You have unsaved changes. Are you sure to discard them and leave the screen?',
[
{ text: "Don't leave", style: 'cancel', onPress: () => {} },
{
text: 'Discard',
style: 'destructive',
// If the user confirmed, then we dispatch the action we blocked earlier
// This will continue the action that had triggered the removal of the screen
onPress: () => navigation.dispatch(e.data.action),
},
]
);
}),
[navigation, hasUnsavedChanges, publication]
);
const addThreadItem = () => {
const newThreadItem = {
Id: threadItems.length.toString(),
EditorData: {
blocks: [],
entityMap: {},
},
IpfsFiles: [],
Text: '',
Mentions: [],
Count: 0,
};
setThreadItems([...threadItems, newThreadItem]);
};
const removeThreadItem = (id: string) => {
const newThreadItems = threadItems.filter((item) => item.Id !== id);
if (newThreadItems.length > 0) {
setActiveThreadItemId(newThreadItems?.[newThreadItems?.length - 1]?.Id as string);
}
setThreadItems(newThreadItems);
};
const selectFile = async (attachmentType: CreatePostFileTypeEnum) => {
try {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: false,
type: ATTACHMENT_MIME_TYPE[attachmentType],
});
result.assets?.forEach((asset) => {
const file = {
name: asset.name,
type: asset.mimeType,
uri: Platform.OS === 'ios' ? asset.uri.replace('file://', '') : asset.uri,
attachmentType,
} as IAttachment;
handleThreadItemAddFile(activeThreadItemId)(file);
});
} catch (err) {
console.warn(err);
return false;
}
};
const handleThreadItemTextChange =
(Id: string) =>
({ value, mentions }: OnTextChangeArgs) => {
const newThreadItems = threadItemsRef?.current?.map((item) => {
if (item.Id === Id) {
const plainText = replaceMentionValues(value, ({ name }) => `@${name}`);
const draftEditorState = convertToDraftJSON(plainText, mentions, 'NickName');
return {
...item,
EditorData: draftEditorState,
Text: value,
Count: plainText.length,
Mentions: mentions,
};
}
return item;
});
setThreadItems(newThreadItems);
};
const handleThreadItemAddFile = (Id: string) => (file: IAttachment) => {
const newThreadItems = threadItemsRef?.current?.map((item) => {
if (item.Id === Id) {
return {
...item,
IpfsFiles: [...item.IpfsFiles, file],
};
}
return item;
});
setThreadItems(newThreadItems);
};
const handleThreadItemRemoveFile = (Id: string) => (file: IAttachment) => {
const newThreadItems = threadItemsRef?.current?.map((item) => {
if (item.Id === Id) {
return {
...item,
IpfsFiles: item.IpfsFiles.filter((f) => f.uri !== file.uri),
};
}
return item;
});
setThreadItems(newThreadItems);
};
const showThreadLine = (index: number) => {
return index !== threadItems.length - 1;
};
const showRemoveButton = (index: number) => {
return index === threadItems.length - 1 && index !== 0;
};
const tabs = [
{
value: PublicationType.blockchain,
label: 'Blockchain',
},
{
value: PublicationType.encrypted,
label: 'Encrypted',
},
];
const handlePublicationTypeChange = (value: PublicationType) => {
setPublicationType(value);
_setThreadItems(INITIAL_THREAD_ITEMS);
};
const handleAddEmoji = (emoji: EmojiType) => {
const newThreadItems = threadItemsRef?.current?.map((item) => {
if (item.Id === activeThreadItemId) {
const newText = (item.Text ?? '') + emoji.emoji;
const plainText = replaceMentionValues(newText, ({ name }) => `@${name}`);
const draftEditorState = convertToDraftJSON(plainText, item.Mentions, 'NickName');
return {
...item,
Text: (item.Text ?? '') + emoji.emoji,
EditorData: draftEditorState,
Count: plainText.length,
};
}
return item;
});
setThreadItems(newThreadItems);
};
const handleMintPress = async () => {
handleMint(threadItems, currency);
};
return (
<Container>
<KeyboardAwareScrollView
contentContainerStyle={{ flexGrow: 1, paddingTop: 90, paddingHorizontal: 16 }}
>
<XStack jc="space-between" space="$6" ai="center" mt="$3">
<XStack ml={8}>
<GradientButton
disabled={submitDisabled}
unstyled
w={18}
h={18}
borderRadius={100_000}
pressStyle={{
opacity: 0.5,
}}
animation="quick"
ai="center"
jc="center"
// circular
// size={18}
bw={0}
onPress={() => addThreadItem()}
>
<MaterialCommunityIcons name="plus-thick" size={8} color="#fff" />
</GradientButton>
</XStack>
<YStack f={1}>
<CustomTabs tabs={tabs} onTabPress={handlePublicationTypeChange} />
</YStack>
</XStack>
<YStack mt={12}>
{threadItems.map((threadItem, i) => (
<NewThreadItem key={threadItem.Id} isActive={activeThreadItemId === threadItem.Id}>
<NewThreadItem.Avatar
src={currentUser?.ImageLink!}
showThreadLine={showThreadLine(i)}
showRemoveButton={showRemoveButton(i)}
onRemovePress={() => removeThreadItem(threadItem.Id)}
/>
<NewThreadItem.Content f={1}>
<TextLengthBedge>
<Paragraph size="$2" color="$color" fontWeight="700">
{threadItem.Count}/5000
</Paragraph>
</TextLengthBedge>
<NewThreadItem.Editor
value={threadItem.Text ?? ''}
onFocus={() => setActiveThreadItemId(threadItem.Id)}
onTextChange={handleThreadItemTextChange(threadItem.Id)}
/>
<AttachmentList
// onPress={() => setActiveThreadItemId(threadItem.Id)}
files={threadItem.IpfsFiles}
onRemoveFile={handleThreadItemRemoveFile(threadItem.Id)}
itemSize={150}
/>
</NewThreadItem.Content>
</NewThreadItem>
))}
</YStack>
<YStack py={20} mt="auto">
{publicationType === PublicationType.blockchain && (
<AttachmentsActionBar
mb={12}
onSelectFile={selectFile}
currency={currency}
onCurrencyChange={setCurrency}
onAddEmoji={handleAddEmoji}
/>
)}
</YStack>
{/* <Modal isVisible={isMinting}>
<YStack f={1} ai="center" jc="center">
<Spinner color="$color" size="large" />
</YStack>
</Modal> */}
</KeyboardAwareScrollView>
</Container>
);
}
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
RelationId,
UpdateDateColumn,
} from 'typeorm';
import Document from '../../bills/entities/document.entity';
import { Team } from '../../teams/entities/team.entity';
import { Like } from 'src/likes/entities/like.entity';
@Entity()
export class Proposal extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'uuid' })
uuid: string;
@Column('jsonb', { nullable: true })
refNodeJson: string;
@Column('text', { nullable: true })
content: string;
@Column('jsonb', { nullable: true })
contentJson: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => Document, (document) => document.proposals)
document?: Document;
@ManyToOne(() => Team, (team) => team.proposals, {
onDelete: 'CASCADE',
})
team?: Team;
@RelationId((proposal: Proposal) => proposal.document)
documentId?: number;
@RelationId((proposal: Proposal) => proposal.team)
teamId: number;
@OneToMany(() => Like, (like) => like.proposal, {
eager: true,
cascade: true,
})
likes: Like[];
@Column('boolean', { nullable: true })
isPublished: boolean;
}
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
} from '@nestjs/common';
import { ProposalsService } from './proposals.service';
import { CreateProposalDto } from './dto/create-proposal.dto';
import { UpdateProposalDto } from './dto/update-proposal.dto';
import { GetProposalsDto } from './dto/get-proposals.dto';
import JwtGuard from 'src/auth/jwt.guard';
import { Roles } from 'src/auth/roles-auth.decorator';
import { Role as RolesEnum } from 'src/auth/role.enum';
import { RolesGuard } from 'src/auth/roles.guard';
@Controller()
export class ProposalsController {
constructor(private readonly proposalsService: ProposalsService) {}
@Post('proposals')
create(@Body() createProposalDto: CreateProposalDto) {
return this.proposalsService.create(createProposalDto);
}
@Get('proposals/bills/:billId')
findAllForBill(@Param('billId') billId: string) {
return this.proposalsService.findByBill(billId);
}
@Get('proposals/documents/:documentId')
findAllForDocument(@Param('documentId') documentId: number) {
const prop = this.proposalsService.findByDocument(documentId);
return prop;
}
@Post('proposals/documents')
async findByDocumentsAndTeam(@Body() getProposalsDto: GetProposalsDto) {
const prop = await this.proposalsService.findByDocumentsAndTeam(
getProposalsDto,
);
return prop;
}
@Post('proposals/documents')
findAllForDocuments(@Body() documentIds: number[]) {
const props = this.proposalsService.findByDocuments(documentIds);
return props;
}
@Get('proposals/:uuid')
findOne(@Param('uuid') uuid: string) {
return this.proposalsService.findOne(uuid);
}
@UseGuards(JwtGuard)
@Patch('proposals/:id')
update(
@Param('id') id: string,
@Body() updateProposalDto: UpdateProposalDto,
) {
return this.proposalsService.update(+id, updateProposalDto);
}
@UseGuards(JwtGuard, RolesGuard)
@Patch('admin/proposals/:id')
@Roles(RolesEnum.User, RolesEnum.Moderator, RolesEnum.SystemAdmin)
updateComment(
@Param('id') id: string,
@Body() updateProposalDto: UpdateProposalDto,
) {
return this.proposalsService.update(+id, updateProposalDto);
}
@Patch('proposals/:id/publish')
publish(@Param('id') id: number, @Body() body: any) {
return this.proposalsService.publish(id, body.userId);
}
@UseGuards(JwtGuard, RolesGuard)
@Roles(RolesEnum.GroupAdmin, RolesEnum.Moderator, RolesEnum.SystemAdmin)
@Delete('proposals/:id')
remove(@Param('id') id: string) {
return this.proposalsService.remove(+id);
}
}
import { Module } from '@nestjs/common';
import { ProposalsService } from './proposals.service';
import { ProposalsController } from './proposals.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Proposal } from './entities/proposal.entity';
import { BillsModule } from 'src/bills/bills.module';
import { UsersModule } from 'src/users/users.module';
@Module({
imports: [TypeOrmModule.forFeature([Proposal]), BillsModule, UsersModule],
controllers: [ProposalsController],
providers: [ProposalsService],
exports: [TypeOrmModule, ProposalsService],
})
export class ProposalsModule {}
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BillsService } from 'src/bills/bills.service';
import { UsersService } from 'src/users/users.service';
import Bill from 'src/bills/entities/bill.entity';
import Document from 'src/bills/entities/document.entity';
import { In, Repository } from 'typeorm';
import { CreateProposalDto } from './dto/create-proposal.dto';
import { UpdateProposalDto } from './dto/update-proposal.dto';
import { GetProposalsDto } from './dto/get-proposals.dto';
import { Proposal } from './entities/proposal.entity';
@Injectable()
export class ProposalsService {
constructor(
@InjectRepository(Proposal)
private readonly proposalRepository: Repository<Proposal>,
private readonly billsService: BillsService,
private readonly usersService: UsersService,
) {}
async create(createProposalDto: CreateProposalDto) {
const bill = await this.billsService.findOneByDocumentId(
createProposalDto.documentId,
);
if (bill.deadline && bill.deadline <= new Date())
throw new NotFoundException(
'The deadline for adding comment is expired.',
);
const newProposal: any = {
uuid: createProposalDto.uuid,
refNodeJson: createProposalDto.refNodeJson,
content: createProposalDto.content,
contentJson: createProposalDto.contentJson,
createdAt: new Date(),
document: {
id: createProposalDto.documentId,
},
team: {
id: createProposalDto.teamId,
},
};
const proposal = this.proposalRepository.create(newProposal);
await this.proposalRepository.save(proposal);
return proposal;
}
async findByBill(billId: string) {
const bill = await this.billsService.findOne(billId);
const documentIds = bill.documents?.length
? bill.documents.map((a) => a.id)
: [];
const data = await this.proposalRepository.find({
where: {
document: In(documentIds),
isPublished: true,
},
relations: ['team'],
});
const uuids = [];
bill.documents.forEach((document) => {
if (!document.contentJson) return;
const json = document.contentJson;
JSON.parse(json, function (key, value) {
if (key === 'data-uuid' && !!value) uuids.push(value);
return value;
});
});
const result = {
billName: bill.name,
data: [],
};
const sortedProposals = data.sort(
(a, b) => uuids.indexOf(a.uuid) - uuids.indexOf(b.uuid),
);
sortedProposals.forEach((a) => {
const refNode = JSON.parse(a.refNodeJson);
if (refNode.type === 'table' && refNode.content?.length) {
let text = '';
let element =
'<table style="border: 1px solid black; width: 100%; border-collapse: collapse;">';
refNode.content.forEach((tContent) => {
if (tContent.type === 'tableRow' && tContent.content?.length) {
element += '<tr style="border: 1px solid black;">';
tContent.content.forEach((rContent) => {
if (rContent.type === 'tableCell' && rContent.content?.length) {
element += '<td style="border: 1px solid black; padding: 5px">';
rContent.content.forEach((cContent) => {
if (
cContent.type === 'paragraph' &&
cContent.content?.length
) {
cContent.content.forEach((pContent) => {
if (pContent.type === 'text' && pContent.text) {
element += `<p>${pContent.text}</p>`;
text += pContent.text + ' ';
}
});
}
});
element += '</td>';
}
});
element += '</tr>';
}
});
element += '<table>';
result.data.push({
text: text,
element: element,
content: a.content,
publishedTime: a.createdAt,
team: a.team ? a.team : '-',
teamId: a.team ? a.team.id : '-',
});
} else {
const firstContent = refNode.content[0];
result.data.push({
text: firstContent.text,
element: null,
content: a.content,
publishedTime: a.createdAt,
team: a.team ? a.team : '-',
teamId: a.team ? a.team.id : '-',
});
}
});
return result;
}
findByDocument(documentId: number) {
return this.proposalRepository.find({ where: { document: documentId } });
}
async findByDocumentsAndTeam(getProposalsDto: GetProposalsDto) {
const proposals = await this.proposalRepository.find({
where: [
{
document: In(getProposalsDto.documentIds),
team: getProposalsDto.teamId,
},
{ document: In(getProposalsDto.documentIds), isPublished: true },
],
relations: ['team'],
});
return proposals.map((proposal) => {
return {
...proposal,
teamName: proposal.team?.name,
};
});
}
findByDocuments(documentIds: number[]) {
return this.proposalRepository.find({
where: { document: In(documentIds) },
});
}
findAll() {
return `This action returns all proposals`;
}
async findOne(uuid: string) {
const proposal = await this.proposalRepository.findOne({ uuid });
if (!proposal) {
throw new NotFoundException(`Can't find proposal with id ${uuid}`);
}
return proposal;
}
async update(id: number, updateProposalDto: UpdateProposalDto) {
const proposal = await this.proposalRepository.preload({
id: +id,
...updateProposalDto,
});
if (!proposal) {
throw new NotFoundException(`Can't find proposal with id ${id}`);
}
await this.proposalRepository.save(proposal);
const updatedProposal = await this.proposalRepository.findOne({
where: { id },
relations: ['team'],
});
return {
...updatedProposal,
teamName: updatedProposal.team?.name,
};
}
async publish(id: number, userId: number) {
const user = await this.usersService.findOne(userId);
if (user) {
const proposal = await this.proposalRepository.preload({
id: id,
...{
isPublished: true,
},
});
if (!proposal) {
throw new NotFoundException(`Can't find proposal with id ${id}`);
}
const _proposal = await this.proposalRepository.findOne(id, {
relations: ['team'],
});
if (
_proposal &&
_proposal.team &&
user.teams.some((a) => a.name === _proposal.team.name)
) {
return this.proposalRepository.save(proposal);
}
}
return {
message: "You can't publish this proposal",
};
}
async remove(id: number) {
const proposal = await this.proposalRepository.preload({
id: +id,
});
if (!proposal) {
throw new NotFoundException(`Can't find proposal with id ${id}`);
}
return this.proposalRepository.remove(proposal);
}
}
// Component from some ts project with react-native
import React, { Suspense, memo, useCallback, useMemo } from 'react';
import { StyleProp, TextStyle, TouchableOpacity } from 'react-native';
import { Linking, Platform } from 'react-native';
import { BlurView as RNBlurView } from '@react-native-community/blur';
import dayjs from 'dayjs';
import { RawDraftContentState } from 'draft-js';
import { BlurView } from 'expo-blur';
import Svg, {
SvgProps,
G,
Path,
Defs,
LinearGradient,
Stop,
ClipPath,
Circle,
} from 'react-native-svg';
import { useRouter } from 'solito/router';
import { PublicationDTO } from '@authencity/api/src';
import {
Button,
H1,
NetworkIcon,
Paragraph,
UserAvatar,
XStack,
XStackProps,
YStack,
YStackProps,
styled,
useTheme,
useToastController,
} from '@authencity/ui';
import { CreatePostFileTypeEnum } from 'app/constants/app';
import useAuth from 'app/context/auth';
import useBackPost from 'app/context/post-backing';
import useColorMode from 'app/hooks/useColorMode';
import { PublicationDecryptProvider, usePublicationDecrypt } from 'app/hooks/useDecryptPublication';
import usePublicationActions from 'app/hooks/usePublicationActions';
import { getOpenSeaLink, getPostTextFromEditorState } from 'app/utils/blockchain';
import { getTimeAgo } from 'app/utils/dates';
import { getTransactionLink } from 'app/utils/networkType';
import Config from '../../../../apps/expo/config';
import { ErrorHandler } from '../ErrorHandler/ErrorHandler';
import ExternalLink from '../ExternalLink';
import {
MentionHashtagTextViewFull,
MentionHashtagTextViewSmall,
} from '../MentionHashtagTextView/MentionHashtagTextView';
import PostingMedia from '../PostingMedia';
import {
IconBookmark,
IconComment,
IconCopy,
IconLike,
IconRetweet,
IconView,
IconOpensea,
} from './PublicationIcons';
import { PublicationLink } from './PublicationLink';
import PublicationSkeleton from './PublicationSkeleton';
/* -------------------------------------------------------------------------------------------------
* PublicationHeader
* -----------------------------------------------------------------------------------------------*/
const Nick = styled(Paragraph, {
name: 'Nick',
fontSize: '$2',
fontWeight: '700',
color: '$secondary',
});
const Name = styled(H1, {
name: 'Name',
fontSize: '$true',
lineHeight: 14,
m: 0,
});
const LockIconClosed = memo((props: SvgProps) => (
<Svg width={24} height={24} fill="none" {...props}>
<Path
fill="currentColor"
fillRule="evenodd"
d="M14.75 7.75v1.7c0 .427.304.85.73.85h.108c.336 0 .631-.237.653-.572.006-.092.009-.184.009-.278v-1.7a4.25 4.25 0 0 0-8.5 0v1.7c0 .094.003.186.009.278.022.335.317.572.653.572h.108c.426 0 .73-.423.73-.85v-1.7a2.75 2.75 0 1 1 5.5 0Z"
clipRule="evenodd"
/>
<Path
stroke="currentColor"
strokeWidth={1.5}
d="M5.95 12.213c0-1.112.901-2.013 2.013-2.013h8.075c1.111 0 2.012.901 2.012 2.013V13.7a6.05 6.05 0 0 1-12.1 0v-1.487Z"
/>
<Circle cx={12} cy={13.7} r={1.375} stroke="currentColor" strokeWidth={1.5} />
<Path stroke="currentColor" strokeLinecap="round" strokeWidth={1.5} d="M12 15.4v1.7" />
</Svg>
));
const LockIconOpen = memo((props: SvgProps) => (
<Svg width={24} height={24} fill="none" {...props}>
<Path
fill="currentColor"
fillRule="evenodd"
d="M14.81 10a.147.147 0 0 0-.14.113.15.15 0 0 0 .14.187h1.228a.151.151 0 0 0 .15-.125.151.151 0 0 0-.15-.175H14.81Zm-.28-4c.63 0 1.032-.652.607-1.117A4.25 4.25 0 0 0 7.75 7.75v1.7c0 .094.003.186.009.278.022.335.317.572.653.572h.108c.426 0 .73-.423.73-.85v-1.7a2.75 2.75 0 0 1 4.589-2.045c.192.173.432.295.69.295Z"
clipRule="evenodd"
/>
<Path
stroke="currentColor"
strokeWidth={1.5}
d="M5.95 12.213c0-1.112.901-2.013 2.013-2.013h8.075c1.111 0 2.012.901 2.012 2.013V13.7a6.05 6.05 0 0 1-12.1 0v-1.487Z"
/>
<Circle cx={12} cy={13.7} r={1.375} stroke="currentColor" strokeWidth={1.5} />
<Path stroke="currentColor" strokeLinecap="round" strokeWidth={1.5} d="M12 15.4v1.7" />
</Svg>
));
interface IPublicationHeaderProps extends Partial<PublicationDTO> {
hideFollowButton?: boolean;
hideAvatar?: boolean;
isOwnPublication?: boolean;
wrapperProps?: XStackProps;
isRetweet?: boolean;
}
const PublicationHeader = (props: IPublicationHeaderProps) => {
const router = useRouter();
const {
Id,
FirstName,
LastName,
NickName,
ImageLink,
CreatorId,
Follow,
IsEncrypted,
NetworkType,
DateCreation,
hideFollowButton,
hideAvatar,
wrapperProps,
isRetweet,
isOwnPublication,
} = props;
const theme = useTheme();
const initials = useMemo(() => {
return FirstName && LastName
? `${FirstName[0]}${LastName[0]}`
: NickName
? NickName.slice(0, 2)
: '';
}, [FirstName, LastName, NickName]);
const fullNameMaybe = useMemo(() => {
const name = [FirstName, LastName].join(' ').trim();
return name ? <Name>{name}</Name> : null;
}, [FirstName, LastName]);
const { decryptPost, decryptedText } = usePublicationDecrypt();
return (
<XStack {...wrapperProps}>
<XStack w="100%" ai="flex-start">
<XStack gap={12}>
{hideAvatar ? null : (
<UserAvatar
src={ImageLink}
size={32}
letter={initials}
onPress={() =>
isOwnPublication
? router.replace({
pathname: '/user',
})
: router.push({
pathname: '/user/[nickName]',
query: {
nickName: NickName,
},
})
}
/>
)}
<YStack>
<XStack ai="baseline">
<TouchableOpacity
onPress={() =>
isOwnPublication
? router.replace({
pathname: '/user',
})
: router.push({
pathname: '/user/[nickName]',
query: {
nickName: NickName,
},
})
}
>
<XStack ai="center">
{fullNameMaybe}
{isRetweet ? (
<Nick fos={10} lh={12}>
{' '}
· reposted {getTimeAgo(DateCreation!)}
</Nick>
) : null}
</XStack>
<Nick>@{NickName}</Nick>
</TouchableOpacity>
</XStack>
</YStack>
</XStack>
<XStack ml="auto" ai="center">
{IsEncrypted ? (
<TouchableOpacity onPress={decryptPost}>
{decryptedText ? (
<LockIconOpen color={theme.color.val} />
) : (
<LockIconClosed color={theme.color.val} />
)}
</TouchableOpacity>
) : (
<NetworkIcon networkType={NetworkType} style={{ marginRight: 5 }} />
)}
{hideFollowButton ? null : <FollowButton CreatorId={CreatorId} Id={Id} Follow={Follow} />}
</XStack>
</XStack>
</XStack>
);
};
PublicationHeader.displayName = 'PublicationHeader';
/* -------------------------------------------------------------------------------------------------
* FollowButton
* -----------------------------------------------------------------------------------------------*/
const LogoIcon = memo((props: SvgProps) => (
<Svg width={16} height={16} viewBox="0 0 16 16" fill="none" {...props}>
<G clipPath="url(#a)">
<Path
fill="url(#b)"
d="M2.908 13.28c.8.8 1.69 1.378 2.672 1.734a6.429 6.429 0 0 0 2.977.36s1.248-.29 1.09-1.148c-.16-.856-1.364-.947-1.364-.947-1.514.166-2.83-.31-3.947-1.426-.982-.982-1.47-2.151-1.462-3.507.008-1.355.501-2.523 1.482-3.503.98-.98 2.15-1.477 3.51-1.489 1.36-.012 2.531.473 3.513 1.456 1.04 1.04 1.525 2.327 1.454 3.861 0 0-.034 1.107.933 1.254.968.147 1.147-.965 1.147-.965a6.735 6.735 0 0 0-.4-2.95c-.357-.973-.925-1.849-1.705-2.629C11.402 1.976 9.73 1.27 7.79 1.263c-1.939-.006-3.594.677-4.966 2.049C1.452 4.684.772 6.337.783 8.272c.01 1.933.72 3.603 2.125 5.009Z"
/>
<Path
fill="url(#c)"
stroke="url(#d)"
d="M5.403 10.738c.371.372.787.638 1.247.8a3 3 0 0 0 1.399.152s.588-.143.519-.546c-.07-.402-.636-.438-.636-.438-.714.087-1.33-.129-1.849-.648a2.18 2.18 0 0 1-.668-1.641c.012-.638.25-1.19.717-1.657a2.342 2.342 0 0 1 1.66-.72 2.173 2.173 0 0 1 1.645.664c.483.483.704 1.086.662 1.809 0 0-.023.52.431.584.455.064.546-.46.546-.46.047-.469-.01-.93-.172-1.386a3.275 3.275 0 0 0-.787-1.227c-.653-.653-1.436-.976-2.348-.968-.912.008-1.695.34-2.348.993-.654.653-.983 1.435-.99 2.344-.005.91.319 1.692.972 2.345Z"
/>
</G>
<Defs>
<LinearGradient
id="b"
x1={-3.738}
x2={9.187}
y1={11.104}
y2={14.658}
gradientUnits="userSpaceOnUse"
>
<Stop stopColor="#00D6D9" />
<Stop offset={1} stopColor="#3C47C6" />
</LinearGradient>
<LinearGradient
id="c"
x1={2.289}
x2={8.313}
y1={9.752}
y2={11.476}
gradientUnits="userSpaceOnUse"
>
<Stop stopColor="#00D6D9" />
<Stop offset={1} stopColor="#3C47C6" />
</LinearGradient>
<LinearGradient
id="d"
x1={2.289}
x2={8.313}
y1={9.752}
y2={11.476}
gradientUnits="userSpaceOnUse"
>
<Stop stopColor="#00D6D9" />
<Stop offset={1} stopColor="#3C47C6" />
</LinearGradient>
<ClipPath id="a">
<Path fill="#fff" d="M0 0h16v16H0z" />
</ClipPath>
</Defs>
</Svg>
));
interface IFollowButtonProps extends Partial<PublicationDTO> {}
const FollowButton = memo((props: IFollowButtonProps) => {
const { colorMode } = useColorMode();
const { follow, unfollow } = usePublicationActions({ userId: props.CreatorId });
const { Follow, CreatorId, Id } = props;
const handleFollowUnfollow = useCallback(() => {
if (Follow) {
unfollow.mutate({
queryParams: {
followingId: CreatorId,
},
});
} else {
follow.mutate({
queryParams: {
followerUserId: CreatorId,
},
});
}
}, [Follow, CreatorId, Id, follow, unfollow]);
return (
<>
<Button
onPress={handleFollowUnfollow}
// backgroundColor={colorMode === 'dark' ? '$background' : '#ecf0f9'}
borderRadius={4}
opacity={0.59}
px={6}
unstyled
fd="row"
ai="center"
jc="space-between"
iconAfter={<LogoIcon />}
height={24}
borderWidth={0}
color="$color"
>
{Follow ? 'Unfollow' : 'Follow'}
</Button>
</>
);
});
/* -------------------------------------------------------------------------------------------------
* PublicationText
* -----------------------------------------------------------------------------------------------*/
interface IPublicationTextProps extends Partial<PublicationDTO> {}
const PublicationText = (props: IPublicationTextProps) => {
const { colorMode } = useColorMode();
const router = useRouter();
const { Text, IsEncrypted, CanDecrypt } = props;
const { decryptedText } = usePublicationDecrypt();
const plainText = useMemo(() => {
if (IsEncrypted && !decryptedText) return Text;
try {
if (decryptedText) {
return getPostTextFromEditorState(
JSON.parse(decryptedText) as unknown as RawDraftContentState
) as string;
}
return getPostTextFromEditorState(
JSON.parse(Text!) as unknown as RawDraftContentState
) as string;
} catch (_error) {
return '';
}
}, [Text, IsEncrypted, decryptedText]) as string;
const onMentionHashtagPress = async (text: string) => {
if (text.startsWith('#')) {
console.log('Clicked to hashtag ' + text);
}
if (text.startsWith('@')) {
console.log('Clicked to mention ' + text);
const nickName = text.replace('@', '');
router.push({
pathname: '/user/[nickName]',
query: {
nickName,
},
});
}
if (text.startsWith('https://')) {
console.log('Clicked to link ' + text);
// TODO: Fix https://stackoverflow.com/questions/64699801/linking-canopenurl-returning-false-when-targeting-android-30-sdk-on-react-native
// const supported = await Linking.canOpenURL('https://twitter.com/elonmusk/status/1642984664091721733/');
// console.log('Supported ' + supported);
// if (supported) {
// Opening the link with some app, if the URL colorMode is "http" the web link should be opened
// by some browser in the mobile
await Linking.openURL(text);
// }
}
};
const onDecrypterPress = (text: string) => {
console.log('Clicked to decrypter ' + text);
router.push({
pathname: '/user/[nickName]',
query: {
nickName: text,
},
});
};
const decrypters = useMemo(() => {
if (!CanDecrypt) return null;
const decryptersList = (CanDecrypt ?? '')
.split(',')
.filter((x) => !!x)
.map((x, i) => {
return (
<Paragraph
key={i}
onPress={() => onDecrypterPress(x)}
color="#1f45c3"
fow="700"
bc="rgba(31,69,195,0.2)"
br={4}
mr={2}
>
@{x}
</Paragraph>
);
});
return decryptersList;
}, [CanDecrypt, onDecrypterPress]);
if (!plainText) return null;
return (
<YStack mt={8}>
{IsEncrypted && !decryptedText ? (
<XStack ai="center">
{decrypters}
<XStack ai="center" f={1}>
<Paragraph color="$paragraph" numberOfLines={1} ellipsizeMode="tail" f={1}>
{plainText}
</Paragraph>
<RNBlurView
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
blurType="light"
blurRadius={1}
blurAmount={1}
overlayColor="transparent"
reducedTransparencyFallbackColor="white"
/>
</XStack>
</XStack>
) : (
<MentionHashtagTextViewSmall mentionHashtagPress={onMentionHashtagPress}>
{plainText}
</MentionHashtagTextViewSmall>
)}
</YStack>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationDate
* -----------------------------------------------------------------------------------------------*/
interface IPublicationDateTimeProps extends Partial<PublicationDTO> {}
const PublicationDateTime = (props: IPublicationDateTimeProps) => {
const { DateCreation } = props;
const { date, time } = useMemo(() => {
return {
date: dayjs(DateCreation).format('DD.MM.YYYY'),
time: dayjs(DateCreation).format('HH:mm'),
};
}, [DateCreation]);
return (
<Paragraph color="#A4A3BB" fos={10} lh={12}>
{date}{' '}
<Paragraph fos={10} lh={12} color="$color">
{time}
</Paragraph>
</Paragraph>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationTxLink
* -----------------------------------------------------------------------------------------------*/
interface IPublicationTxLinkProps extends Partial<PublicationDTO> {}
const PublicationTxLink = (props: IPublicationTxLinkProps) => {
const { NetworkType, BlockchainId, PublicationKey } = props;
const firstPublicationKey = String(PublicationKey).split(';')[0]?.trim();
return (
<ExternalLink url={getTransactionLink(NetworkType!, firstPublicationKey!, BlockchainId!)}>
<Paragraph fos={10} lh={12} color="rgba(94, 202, 244, 1)" textDecorationLine="underline">
{getTransactionLink(NetworkType!, firstPublicationKey!, BlockchainId!).slice(0, 30).trim()}
...
</Paragraph>
</ExternalLink>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationRetweet
* -----------------------------------------------------------------------------------------------*/
interface IPublicationRetweetProps extends Partial<PublicationDTO> {}
export const PublicationRetweet = (props: IPublicationRetweetProps) => {
const { RetweetData, Retweet } = props;
if (!Retweet || !RetweetData) return null;
return (
<PublicationLink id={RetweetData.Id!}>
<YStack
mt={8}
mb={1}
p={8}
style={{
borderRadius: 8,
borderWidth: 1,
borderStyle: Platform.OS === 'ios' ? 'solid' : 'dashed',
borderColor: 'rgba(165,164,188,0.25)',
}}
>
<PublicationHeader {...RetweetData} hideFollowButton />
<PublicationText {...RetweetData} />
<PostingMedia IpfsFiles={RetweetData.IpfsFiles!} />
</YStack>
</PublicationLink>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationActions
* -----------------------------------------------------------------------------------------------*/
const XCenter = styled(XStack, {
alignItems: 'center',
justifyContent: 'center',
});
const PostActionText = styled(Paragraph, {
name: 'PostActionText',
fontSize: 10,
fontWeight: '700',
lineHeight: 14,
ml: 2,
});
const AnchorIcon = memo((props: SvgProps) => (
<Svg width={16} height={16} fill="none" {...props}>
<Path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M8.824 7.177a2.33 2.33 0 0 0-3.294 0L3.882 8.824a2.33 2.33 0 0 0 3.295 3.294L8 11.295"
/>
<Path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M7.177 8.824a2.33 2.33 0 0 0 3.294 0l1.647-1.647a2.33 2.33 0 0 0-3.294-3.295L8 4.706"
/>
</Svg>
));
interface IPublicationActionsProps extends Partial<PublicationDTO> {}
const PublicationActions = (props: IPublicationActionsProps) => {
const theme = useTheme();
const {
Text,
IsEncrypted,
CommentCount,
Retweet,
RetweetCount,
RetweetData,
Id,
LikeCount,
Liked,
Favorite,
NetworkType,
IpfsFiles,
ViewsCount,
BlockchainId,
CreatorId,
} = props;
const router = useRouter();
const { newLike, removeFromFavorites, addToFavorites, copyToClipboard } = usePublicationActions({
userId: CreatorId,
});
const hasRetweet = !!Retweet && RetweetData;
const hasVideo = IpfsFiles?.some((file) => file.FileType === CreatePostFileTypeEnum.video);
const plainText = useMemo(() => {
if (IsEncrypted) return Text;
try {
return getPostTextFromEditorState(
JSON.parse(Text!) as unknown as RawDraftContentState
) as string;
} catch (_error) {
return '';
}
}, [Text, IsEncrypted]) as string;
const handleLike = () => {
newLike.mutate({
body: {
BlockchainId,
LikeObjectId: Id,
},
});
};
const handleFavorite = () => {
if (Favorite) {
removeFromFavorites.mutate({
queryParams: {
publicationId: Id,
},
});
} else {
addToFavorites.mutate({
queryParams: {
publicationId: Id,
},
});
}
};
return (
<XStack jc="space-between" mt={12}>
<XStack>
<TouchableOpacity
style={{ marginRight: 1, padding: 5, borderRadius: 5 }}
onPress={() => {
router.push({
pathname: '/comment/new/[postId]',
query: {
postId: Id,
},
});
}}
>
<XCenter>
{/* @ts-ignore */}
<IconComment color={theme.color.val} />
<PostActionText>{CommentCount}</PostActionText>
</XCenter>
</TouchableOpacity>
<TouchableOpacity
style={{ marginRight: 1, padding: 5, borderRadius: 5 }}
onPress={() => {
router.push({
pathname: '/repost/new/[postId]',
query: {
postId: hasRetweet ? RetweetData.Id : Id,
},
});
}}
>
<XCenter>
{/* @ts-ignore */}
<IconRetweet color={theme.color.val} />
<PostActionText>{RetweetCount}</PostActionText>
</XCenter>
</TouchableOpacity>
<TouchableOpacity
style={{ marginRight: 1, padding: 5, borderRadius: 5 }}
onPress={handleLike}
>
<XCenter>
{/* @ts-ignore */}
<IconLike color={theme.color.val} focused={Boolean(Liked)} />
<PostActionText>{LikeCount}</PostActionText>
</XCenter>
</TouchableOpacity>
{hasVideo && (
<XCenter mr={1} p={5}>
{/* @ts-ignore */}
<IconView color={theme.color.val} />
<PostActionText>{ViewsCount}</PostActionText>
</XCenter>
)}
<TouchableOpacity
style={{ marginRight: 1, padding: 5, borderRadius: 5 }}
onPress={handleFavorite}
>
{/* @ts-ignore */}
<IconBookmark color={theme.color.val} focused={Boolean(Favorite)} />
</TouchableOpacity>
<TouchableOpacity
style={{ marginRight: 1, padding: 5, borderRadius: 5 }}
onPress={() => copyToClipboard(Config.API_URL + `/post/${Id}`)}
>
{/* @ts-ignore */}
<IconCopy color={theme.color.val} />
</TouchableOpacity>
</XStack>
<XStack>
<ExternalLink url={getOpenSeaLink(NetworkType!, BlockchainId!)}>
<XStack p={5}>
{/* @ts-ignore */}
<IconOpensea color={theme.color.val} />
</XStack>
</ExternalLink>
</XStack>
</XStack>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationThreadWrapper
* -----------------------------------------------------------------------------------------------*/
interface IPublicationThreadWrapperProps extends Partial<PublicationDTO> {
children: React.ReactNode;
}
const PublicationThreadWrapper = (props: IPublicationThreadWrapperProps) => {
const { Id, IsThread, ImageLink, FirstName, LastName, NickName, children } = props;
const router = useRouter();
const initials = useMemo(() => {
return FirstName && LastName
? `${FirstName[0]}${LastName[0]}`
: NickName
? NickName.slice(0, 2)
: '';
}, [FirstName, LastName, NickName]);
if (!IsThread) return <>{children}</>;
return (
<>
<XStack>
<YStack ai="center" width={30}>
<YStack f={1} w={1} backgroundColor="$color" opacity={0.15} />
</YStack>
<YStack f={1} pb={10}>
{children}
</YStack>
</XStack>
<XStack ai="center" gap={6}>
<XStack w={30} ai="center" jc="center">
<UserAvatar
src={ImageLink}
size={24}
letter={initials}
onPress={() =>
router.push({
pathname: '/user/[nickName]',
query: {
nickName: NickName,
},
})
}
/>
</XStack>
<TouchableOpacity
onPress={() => {
router.push({
pathname: '/thread/[id]',
query: {
id: Id,
},
});
}}
>
<Paragraph color="#3C47C6">Show this thread</Paragraph>
</TouchableOpacity>
</XStack>
</>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationCell
* -----------------------------------------------------------------------------------------------*/
interface IPublicationCellProps extends YStackProps {
publication: PublicationDTO;
}
const PublicationCell = (props: IPublicationCellProps) => {
const { currentUser } = useAuth();
const { publication, ...rest } = props;
const isOwnPublication = currentUser?.Id === publication?.CreatorId;
const publicationType = publication?.IsThread ? 'thread' : 'post';
const isRetweet = !!publication?.Retweet;
return (
<ErrorHandler FallbackComponent={null}>
<Suspense fallback={<PublicationSkeleton />}>
<PublicationDecryptProvider publication={publication}>
<PublicationLink id={publication.Id!} type={publicationType}>
<YStack py={16} {...rest}>
<PublicationHeader
{...publication}
hideFollowButton={isOwnPublication}
isRetweet={isRetweet}
isOwnPublication={isOwnPublication}
/>
<PublicationThreadWrapper {...publication}>
<PublicationText {...publication} />
<PublicationRetweet {...publication} />
<PostingMedia IpfsFiles={publication.IpfsFiles!} />
<XStack ai="center" jc="space-between" mt={4}>
<PublicationDateTime {...publication} />
<PublicationTxLink {...publication} />
</XStack>
<PublicationActions {...publication} />
</PublicationThreadWrapper>
</YStack>
</PublicationLink>
</PublicationDecryptProvider>
</Suspense>
</ErrorHandler>
);
};
PublicationCell.displayName = 'PublicationCell';
export default memo(PublicationCell);
/* -------------------------------------------------------------------------------------------------
* PublicationDetailsText
* -----------------------------------------------------------------------------------------------*/
interface IPublicationDetailsTextProps extends Partial<PublicationDTO> {
textStyle?: StyleProp<TextStyle>;
}
const PublicationDetailsText = (props: IPublicationDetailsTextProps) => {
const { colorMode } = useColorMode();
const router = useRouter();
const { Text, IsEncrypted, CanDecrypt, textStyle } = props;
const { decryptedText } = usePublicationDecrypt();
const plainText = useMemo(() => {
if (IsEncrypted && !decryptedText) return Text;
try {
if (decryptedText) {
return getPostTextFromEditorState(
JSON.parse(decryptedText) as unknown as RawDraftContentState
) as string;
}
return getPostTextFromEditorState(
JSON.parse(Text!) as unknown as RawDraftContentState
) as string;
} catch (_error) {
return '';
}
}, [Text, IsEncrypted, decryptedText]) as string;
const onMentionHashtagPress = async (text: string) => {
if (text.startsWith('#')) {
console.log('Clicked to hashtag ' + text);
}
if (text.startsWith('@')) {
console.log('Clicked to mention ' + text);
const nickName = text.replace('@', '');
router.push({
pathname: '/user/[nickName]',
query: {
nickName,
},
});
}
if (text.startsWith('https://')) {
console.log('Clicked to link ' + text);
// TODO: Fix https://stackoverflow.com/questions/64699801/linking-canopenurl-returning-false-when-targeting-android-30-sdk-on-react-native
// const supported = await Linking.canOpenURL('https://twitter.com/elonmusk/status/1642984664091721733/');
// console.log('Supported ' + supported);
// if (supported) {
// Opening the link with some app, if the URL colorMode is "http" the web link should be opened
// by some browser in the mobile
await Linking.openURL(text);
// }
}
};
const onDecrypterPress = (text: string) => {
console.log('Clicked to decrypter ' + text);
router.push({
pathname: '/user/[nickName]',
query: {
nickName: text,
},
});
};
const decrypters = useMemo(() => {
if (!CanDecrypt) return null;
const decryptersList = (CanDecrypt ?? '')
.split(',')
.filter((x) => !!x)
.map((x, i) => {
return (
<Paragraph
key={i}
onPress={() => onDecrypterPress(x)}
color="#1f45c3"
fow="700"
bc="rgba(31,69,195,0.2)"
br={4}
mr={2}
>
@{x}
</Paragraph>
);
});
return decryptersList;
}, [CanDecrypt, onDecrypterPress]);
if (!plainText) return null;
return (
<YStack mt={8}>
{IsEncrypted && !decryptedText ? (
<XStack ai="center">
{decrypters}
<XStack ai="center" f={1}>
<Paragraph
fos={16}
color="$paragraph"
numberOfLines={1}
ellipsizeMode="tail"
ellipse
f={1}
>
{plainText}
</Paragraph>
<RNBlurView
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
blurType="xlight"
blurRadius={1}
blurAmount={1}
overlayColor="transparent"
reducedTransparencyFallbackColor="white"
/>
</XStack>
</XStack>
) : (
<MentionHashtagTextViewFull
mentionHashtagPress={onMentionHashtagPress}
fontSize={16}
lh={22}
fontWeight="700"
color="$color"
style={textStyle}
>
{plainText}
</MentionHashtagTextViewFull>
)}
</YStack>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationBacking
* -----------------------------------------------------------------------------------------------*/
interface IPublicationBackingProps extends Partial<PublicationDTO> {}
const PublicationBacking = (props: IPublicationBackingProps) => {
const { setCurrentBackingPublicationId } = useBackPost();
const { Id, BackedCount } = props;
const handleBackPostPress = () => {
setCurrentBackingPublicationId(Id);
};
return (
<TouchableOpacity onPress={handleBackPostPress}>
<XStack ai="center" jc="flex-start" mt={8} gap={4}>
<Paragraph fos={10} color="$secondary">
Backed by:{' '}
<Paragraph fos={12} fontWeight="700">
{BackedCount}
</Paragraph>
</Paragraph>
<LogoIcon width={12} height={12} />
</XStack>
</TouchableOpacity>
);
};
/* -------------------------------------------------------------------------------------------------
* PublicationDetails
* -----------------------------------------------------------------------------------------------*/
interface IPublicationDetailsProps extends YStackProps {
publication: PublicationDTO;
}
export const PublicationDetails = (props: IPublicationDetailsProps) => {
const { publication, ...rest } = props;
const { currentUser } = useAuth();
const isOwnPublication = currentUser?.Id === publication?.CreatorId;
return (
<ErrorHandler FallbackComponent={null}>
<Suspense fallback={<PublicationSkeleton />}>
<PublicationDecryptProvider publication={publication}>
<YStack py={16} {...rest}>
<PublicationHeader {...publication} hideFollowButton={isOwnPublication} />
<PublicationBacking {...publication} />
<PublicationDetailsText {...publication} />
<PublicationRetweet {...publication} />
<PostingMedia IpfsFiles={publication.IpfsFiles!} />
<XStack ai="center" jc="space-between" mt={4}>
<PublicationDateTime {...publication} />
<PublicationTxLink {...publication} />
</XStack>
<PublicationActions {...publication} />
</YStack>
</PublicationDecryptProvider>
</Suspense>
</ErrorHandler>
);
};
/* -------------------------------------------------------------------------------------------------
* ThreadPublicationDetails
* -----------------------------------------------------------------------------------------------*/
interface IThreadPublicationDetailsProps extends YStackProps {
publication: PublicationDTO;
type: 'head' | 'body' | 'tail';
}
export const ThreadPublicationDetails = (props: IThreadPublicationDetailsProps) => {
const { currentUser } = useAuth();
const { publication, type, ...rest } = props;
const shouldHideFollowButton = currentUser?.Id === publication?.CreatorId || type !== 'head';
const textStyle = type !== 'head' ? { fontSize: 12, lineHeight: 19, fontWeight: 'normal' } : {};
return (
<ErrorHandler FallbackComponent={null}>
<Suspense fallback={<PublicationSkeleton />}>
<PublicationLink id={publication.Id!}>
<YStack {...rest}>
<ThreadPublicationSideWrapper {...publication} type={type}>
<PublicationDecryptProvider publication={publication}>
<PublicationHeader
{...publication}
hideFollowButton={shouldHideFollowButton}
hideAvatar
/>
<PublicationDetailsText {...publication} textStyle={textStyle as TextStyle} />
<PublicationRetweet {...publication} />
<PostingMedia IpfsFiles={publication.IpfsFiles!} />
<XStack ai="center" jc="space-between" mt={4}>
<PublicationDateTime {...publication} />
<PublicationTxLink {...publication} />
</XStack>
<PublicationActions {...publication} />
</PublicationDecryptProvider>
</ThreadPublicationSideWrapper>
</YStack>
</PublicationLink>
</Suspense>
</ErrorHandler>
);
};
/* -------------------------------------------------------------------------------------------------
* ThreadPublicationSideWrapper
* -----------------------------------------------------------------------------------------------*/
interface IThreadPublicationSideWrapperProps extends Partial<PublicationDTO> {
children: React.ReactNode;
type: 'head' | 'body' | 'tail';
}
const ThreadPublicationSideWrapper = (props: IThreadPublicationSideWrapperProps) => {
const { Id, IsThread, ImageLink, FirstName, LastName, NickName, children, type } = props;
const router = useRouter();
const initials = useMemo(() => {
return FirstName && LastName
? `${FirstName[0]}${LastName[0]}`
: NickName
? NickName.slice(0, 2)
: '';
}, [FirstName, LastName, NickName]);
const avatarSize = type === 'head' ? 32 : 24;
return (
<>
<XStack>
<YStack ai="center" width={32} pt={type === 'head' ? 16 : 0}>
<YStack pos="absolute" top={16}>
<UserAvatar
src={ImageLink}
size={avatarSize}
letter={initials}
onPress={() =>
router.push({
pathname: '/user/[nickName]',
query: {
nickName: NickName,
},
})
}
/>
</YStack>
<YStack
f={1}
w={1}
backgroundColor="$color"
opacity={0.15}
maxHeight={type === 'tail' ? 16 : undefined}
/>
</YStack>
<YStack
f={1}
pt={16}
pb={10}
pl={8}
borderBottomWidth={type === 'tail' ? 0 : 1}
borderBottomColor="rgba(165,164,188,0.25)"
borderStyle={Platform.OS === 'ios' ? 'solid' : 'dashed'}
>
{children}
</YStack>
</XStack>
</>
);
};
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles-auth.decorator';
import { User } from 'src/users/entities/user.entity';
import { Role as RoleEnum } from './role.enum';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
try {
const requiredRoles = this.reflector.getAllAndOverride<RoleEnum[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
const req = context.switchToHttp().getRequest();
const user: User = req.user;
return user.roles.some((role) =>
requiredRoles.includes(role.value as RoleEnum),
);
} catch (e) {
console.log(e);
throw new ForbiddenException();
}
}
}
import * as os from 'os';
const { parentPort } = require('worker_threads');
import * as Parser from 'rss-parser';
import { createConnection, Repository } from "typeorm";
let parser = new Parser();
import * as Cabin from 'cabin';
import * as pMap from 'p-map';
import * as puppeteer from 'puppeteer';
import Bill from '../entity/Bill';
import Document from '../entity/Document';
import Category from '../entity/Category';
const logger = new Cabin();
let isCancelled = false;
const concurrency = os.cpus().length;
interface IRssItem {
title: string;
link: string;
contentSnippet: string;
categories: string[];
}
const rssLinks = [
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Administrat%C4%ABv%C4%81s%20ties%C4%ABbas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Bud%C5%BEets%2C%20nodok%C4%BCi%2C%20nodevas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Cilv%C4%93kties%C4%ABbas%2C%20b%C4%93rnu%20ties%C4%ABbas%2C%20ties%C4%ABbsargs',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Civilties%C4%ABbas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Darba%20ties%C4%ABbas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Lietved%C4%ABba%2C%20arh%C4%ABvi%2C%20dati',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=IIzgl%C4%ABt%C4%ABba%2C%20zin%C4%81tne%2C%20kult%C5%ABra%2C%20sports',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Komercties%C4%ABbas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Krimin%C4%81lties%C4%ABbas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Lauksaimniec%C4%ABba%2C%20me%C5%BEsaimniec%C4%ABba%2C%20zivsaimniec%C4%ABba',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Vide%2C%20dabas%20aizsardz%C4%ABba',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Mediji%2C%20rekl%C4%81ma%2C%20intelektu%C4%81lais%20%C4%ABpa%C5%A1ums',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Muita%2C%20valsts%20robe%C5%BEa',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Nekustamais%20%C4%ABpa%C5%A1ums%2C%20b%C5%ABvniec%C4%ABba',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Finan%C5%A1u%20tirgus%2C%20bankas%2C%20apdro%C5%A1in%C4%81%C5%A1ana%2C%20azartsp%C4%93les%2C%20izlozes',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Re%C4%A3ion%C4%81l%C4%81%20politika',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Pilson%C4%ABba%2C%20imigr%C4%81cija%2C%20patv%C4%93rums%2C%20personas%20statuss',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Biedr%C4%ABbas%20un%20nodibin%C4%81jumi%2C%20reli%C4%A3ija',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Soci%C4%81l%C4%81%20aizsardz%C4%ABba%2C%20vesel%C4%ABba',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Starptautisk%C4%81%20sadarb%C4%ABba%2C%20ES',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Tiesu%20vara%2C%20policija%2C%20korupcijas%20nov%C4%93r%C5%A1ana',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=P%C4%81rtika%2C%20tirdzniec%C4%ABba%2C%20pat%C4%93r%C4%93t%C4%81ju%20ties%C4%ABbas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=T%C5%ABrisms%2C%20transports%2C%20ostas%2C%20sakari%2C%20ener%C4%A3%C4%93tika',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Valsts%20dro%C5%A1%C4%ABba%20un%20aizsardz%C4%ABba',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Valsts%20instit%C5%ABcijas%2C%20civildienests',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Valsts%20simboli%2C%20valoda%2C%20sv%C4%93tki',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Konstitucion%C4%81l%C4%81s%20ties%C4%ABbas%2C%20v%C4%93l%C4%93%C5%A1anas',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=Publiskais%20iepirkums',
'http://titania.saeima.lv/LIVS13/saeimalivs13.nsf/livs.rss?openagent&cat=COVID%20-19%20',
]
const preloadBillByName = async (name: string, billRepository: Repository<Bill>) => {
const existingBill = await billRepository.findOne({ name })
if (existingBill) return existingBill;
return billRepository.create({ name })
}
const preloadCategoryByName = async (name: string, categoryRepository: Repository<Category>) => {
const existingCategory = await categoryRepository.findOne({ name })
if (existingCategory) return existingCategory;
return categoryRepository.create({ name })
}
async function mapper(
item: IRssItem,
browser: puppeteer.Browser,
billRepository: Repository<Bill>,
documentRepository: Repository<Document>,
categoryRepository: Repository<Category>
) {
// return early if the job was already cancelled
if (isCancelled) return;
const page = await browser.newPage();
try {
await page.goto(item.link);
await page.content();
const documentLinks = await page.evaluate((sel: string) => {
const elements: HTMLAnchorElement[] = Array.from(document.querySelectorAll(sel));
return elements.map(element => {
return {
name: element.innerText.trim(),
content: element.href,
}
})
}, '.embView > .docListBlock div:nth-child(2) a');
const pages = documentLinks.map(async (dl) => {
const existingDocument = await documentRepository.findOne({ name: dl.name })
if (existingDocument) return existingDocument;
const newPage = await browser.newPage();
try {
await newPage.goto(dl.content);
const content = await newPage.content();
const doc = {
name: dl.name,
content
};
return documentRepository.create({ ...doc })
} catch (error) {
console.log(error)
} finally {
await newPage.close();
}
})
const bill = await preloadBillByName(
`${item.title} ${item.contentSnippet}`,
billRepository
);
const documentPages = await Promise.all(pages);
bill.documents = documentPages;
bill.category = await preloadCategoryByName(item.categories[0], categoryRepository);
await billRepository.save(bill)
return { [item.title.trim()]: documentLinks };
} catch (err) {
logger.error(err);
} finally {
await page.close()
}
}
if (parentPort)
parentPort.once('message', message => {
if (message === 'cancel') isCancelled = true;
});
(async () => {
const connection = await createConnection();
const billRepository = connection.getRepository(Bill);
const documentRepository = connection.getRepository(Document);
const categoryRepository = connection.getRepository(Category);
const allFeed = await pMap(rssLinks, async (url) => await parser.parseURL(url), { concurrency })
const items = allFeed.reduce((acc, curr) => {
return [...acc, ...curr.items]
}, [])
const browser = await puppeteer.launch({
executablePath: 'google-chrome-stable',
args: [
'--no-sandbox',
'--disable-setuid-sandbox'
]
});
await pMap(
items,
(item: IRssItem) => mapper(
item,
browser,
billRepository,
documentRepository,
categoryRepository
),
{ concurrency }
);
await browser.close()
// signal to parent that the job is done
if (parentPort) parentPort.postMessage('done');
else process.exit(0);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment