docs: enhance component documentation with usage and reference tracking
- Add comprehensive JSDoc comments to HomeView and InfiniteScroll components - Document method visibility (@public/@internal) and usage contexts - Add clear references to where methods are called from (template, components, lifecycle) - Include file-level documentation with component descriptions - Document component dependencies and template usage - Add parameter and return type documentation - Clarify method call chains and dependencies - Document event emissions and component interactions This commit improves code maintainability by making method usage and component relationships more explicit in the documentation.
This commit is contained in:
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* @file InfiniteScroll.vue
|
||||
* @description A Vue component that implements infinite scrolling functionality using the Intersection Observer API.
|
||||
* This component emits a 'reached-bottom' event when the user scrolls near the bottom of the content.
|
||||
* It includes debouncing to prevent multiple rapid triggers and loading state management.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
<template>
|
||||
<div ref="scrollContainer">
|
||||
<slot />
|
||||
@@ -8,15 +18,51 @@
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
|
||||
|
||||
/**
|
||||
* InfiniteScroll Component
|
||||
*
|
||||
* This component implements infinite scrolling functionality by observing when a user
|
||||
* scrolls near the bottom of the content. It uses the Intersection Observer API for
|
||||
* efficient scroll detection and includes debouncing to prevent multiple rapid triggers.
|
||||
*
|
||||
* Usage in template:
|
||||
* ```vue
|
||||
* <InfiniteScroll @reached-bottom="loadMore">
|
||||
* <div>Content goes here</div>
|
||||
* </InfiniteScroll>
|
||||
* ```
|
||||
*
|
||||
* Props:
|
||||
* - distance: number (default: 200) - Distance in pixels from the bottom at which to trigger the event
|
||||
*
|
||||
* Events:
|
||||
* - reached-bottom: Emitted when the user scrolls near the bottom of the content
|
||||
*/
|
||||
@Component
|
||||
export default class InfiniteScroll extends Vue {
|
||||
/** Distance in pixels from the bottom at which to trigger the reached-bottom event */
|
||||
@Prop({ default: 200 })
|
||||
readonly distance!: number;
|
||||
|
||||
/** Intersection Observer instance for detecting scroll position */
|
||||
private observer!: IntersectionObserver;
|
||||
|
||||
/** Flag to track initial render state */
|
||||
private isInitialRender = true;
|
||||
|
||||
/** Flag to prevent multiple simultaneous loading states */
|
||||
private isLoading = false;
|
||||
|
||||
/** Timeout ID for debouncing scroll events */
|
||||
private debounceTimeout: number | null = null;
|
||||
|
||||
/**
|
||||
* Vue lifecycle hook that runs after component updates.
|
||||
* Initializes the Intersection Observer if not already set up.
|
||||
*
|
||||
* @internal
|
||||
* Used internally by Vue's lifecycle system
|
||||
*/
|
||||
updated() {
|
||||
if (!this.observer) {
|
||||
const options = {
|
||||
@@ -32,7 +78,13 @@ export default class InfiniteScroll extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
// 'beforeUnmount' hook runs before unmounting the component
|
||||
/**
|
||||
* Vue lifecycle hook that runs before component unmounting.
|
||||
* Cleans up the Intersection Observer and any pending timeouts.
|
||||
*
|
||||
* @internal
|
||||
* Used internally by Vue's lifecycle system
|
||||
*/
|
||||
beforeUnmount() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
@@ -42,6 +94,17 @@ export default class InfiniteScroll extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles intersection observer callbacks when the sentinel element becomes visible.
|
||||
* Implements debouncing to prevent multiple rapid triggers and manages loading state.
|
||||
*
|
||||
* @param entries - Array of IntersectionObserverEntry objects
|
||||
* @returns false (required by @Emit decorator)
|
||||
*
|
||||
* @internal
|
||||
* Used internally by the Intersection Observer
|
||||
* @emits reached-bottom - Emitted when the user scrolls near the bottom
|
||||
*/
|
||||
@Emit("reached-bottom")
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
const entry = entries[0];
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* @file HomeView.vue
|
||||
* @description Main view component for the application's home page. Handles user identity, feed management,
|
||||
* and interaction with various dialogs and components. Implements infinite scrolling for activity feed
|
||||
* and manages user registration status.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
<template>
|
||||
<QuickNav selected="Home" />
|
||||
<TopMessage />
|
||||
@@ -345,13 +355,30 @@ import { logger } from "../utils/logger";
|
||||
import { GiveRecordWithContactInfo } from "types";
|
||||
|
||||
/**
|
||||
* HomeView - Main view component for the application's home page
|
||||
*
|
||||
* Workflow:
|
||||
* 1. On mount, initializes user identity, settings, and data
|
||||
* 2. Handles user registration status
|
||||
* 3. Manages feed of activities and offers
|
||||
* 4. Provides interface for creating and viewing claims
|
||||
* HomeView Component
|
||||
*
|
||||
* Main view component that handles:
|
||||
* 1. User identity and registration management
|
||||
* 2. Activity feed with infinite scrolling
|
||||
* 3. Contact management and display
|
||||
* 4. Gift/claim creation and viewing
|
||||
* 5. Feed filtering and settings
|
||||
*
|
||||
* Template Usage:
|
||||
* ```vue
|
||||
* <HomeView>
|
||||
* <!-- Content is managed internally -->
|
||||
* </HomeView>
|
||||
* ```
|
||||
*
|
||||
* Component Dependencies:
|
||||
* - QuickNav: Navigation component
|
||||
* - TopMessage: Message display component
|
||||
* - OnboardingDialog: User onboarding flow
|
||||
* - GiftedDialog: Gift creation interface
|
||||
* - FeedFilters: Feed filtering options
|
||||
* - InfiniteScroll: Infinite scrolling functionality
|
||||
* - ActivityListItem: Individual activity display
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
@@ -417,6 +444,9 @@ export default class HomeView extends Vue {
|
||||
* 5. Load feed data
|
||||
* 6. Load new offers
|
||||
* 7. Check onboarding status
|
||||
*
|
||||
* @internal
|
||||
* Called automatically by Vue lifecycle system
|
||||
*/
|
||||
async mounted() {
|
||||
try {
|
||||
@@ -436,6 +466,11 @@ export default class HomeView extends Vue {
|
||||
* Initializes user identity
|
||||
* - Retrieves existing DIDs
|
||||
* - Creates new DID if none exists
|
||||
* - Loads user settings and contacts
|
||||
* - Checks registration status
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted()
|
||||
* @throws Logs error if DID retrieval fails
|
||||
*/
|
||||
private async initializeIdentity() {
|
||||
@@ -541,6 +576,9 @@ export default class HomeView extends Vue {
|
||||
* - Feed filters and view settings
|
||||
* - Registration status
|
||||
* - Notification acknowledgments
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted() and reloadFeedOnChange()
|
||||
*/
|
||||
private async loadSettings() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
@@ -562,6 +600,9 @@ export default class HomeView extends Vue {
|
||||
/**
|
||||
* Loads user contacts from database
|
||||
* Used for displaying contact info in feed and actions
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted() and initializeIdentity()
|
||||
*/
|
||||
private async loadContacts() {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
@@ -572,6 +613,9 @@ export default class HomeView extends Vue {
|
||||
* - Checks if unregistered user can access API
|
||||
* - Updates registration status if successful
|
||||
* - Preserves unregistered state on failure
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted() and initializeIdentity()
|
||||
*/
|
||||
private async checkRegistrationStatus() {
|
||||
if (!this.isRegistered && this.activeDid) {
|
||||
@@ -598,6 +642,9 @@ export default class HomeView extends Vue {
|
||||
/**
|
||||
* Initializes feed data
|
||||
* Triggers updateAllFeed() to populate activity feed
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted()
|
||||
*/
|
||||
private async loadFeedData() {
|
||||
await this.updateAllFeed();
|
||||
@@ -609,6 +656,9 @@ export default class HomeView extends Vue {
|
||||
* - Number of new direct offers
|
||||
* - Number of new project offers
|
||||
* - Rate limit status for both
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted() and initializeIdentity()
|
||||
* @requires Active DID
|
||||
*/
|
||||
private async loadNewOffers() {
|
||||
@@ -636,6 +686,9 @@ export default class HomeView extends Vue {
|
||||
/**
|
||||
* Checks if user needs onboarding
|
||||
* Opens onboarding dialog if not completed
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted()
|
||||
*/
|
||||
private async checkOnboarding() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
@@ -648,6 +701,9 @@ export default class HomeView extends Vue {
|
||||
* Handles errors during initialization
|
||||
* - Logs error to console and database
|
||||
* - Displays user notification
|
||||
*
|
||||
* @internal
|
||||
* Called by mounted() and handleFeedError()
|
||||
* @param err Error object with optional userMessage
|
||||
*/
|
||||
private handleError(err: unknown) {
|
||||
@@ -667,6 +723,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Checks if feed results are being filtered
|
||||
*
|
||||
* @public
|
||||
* Used in template for filter button display
|
||||
* @returns true if visible or nearby filters are active
|
||||
*/
|
||||
resultsAreFiltered() {
|
||||
@@ -675,6 +734,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Checks if browser notifications are supported
|
||||
*
|
||||
* @public
|
||||
* Used in template for notification feature detection
|
||||
* @returns true if Notification API is available
|
||||
*/
|
||||
notificationsSupported() {
|
||||
@@ -686,6 +748,9 @@ export default class HomeView extends Vue {
|
||||
* - Updates filter states
|
||||
* - Clears existing feed data
|
||||
* - Triggers new feed load
|
||||
*
|
||||
* @public
|
||||
* Called by FeedFilters component when filters change
|
||||
*/
|
||||
async reloadFeedOnChange() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
@@ -700,6 +765,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Loads more feed items for infinite scroll
|
||||
*
|
||||
* @public
|
||||
* Called by InfiniteScroll component when bottom is reached
|
||||
* @param payload Boolean indicating if more items should be loaded
|
||||
*/
|
||||
async loadMoreGives(payload: boolean) {
|
||||
@@ -711,6 +779,15 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if coordinates fall within any search box
|
||||
*
|
||||
* @internal
|
||||
* Called by shouldIncludeRecord() for location-based filtering
|
||||
* @param lat Latitude to check
|
||||
* @param long Longitude to check
|
||||
* @returns true if coordinates are within any search box
|
||||
*/
|
||||
latLongInAnySearchBox(lat: number, long: number) {
|
||||
for (const boxInfo of this.searchBoxes) {
|
||||
if (
|
||||
@@ -729,6 +806,9 @@ export default class HomeView extends Vue {
|
||||
* - Handles filtering of results
|
||||
* - Updates last viewed claim ID
|
||||
* - Manages loading state
|
||||
*
|
||||
* @public
|
||||
* Called by loadMoreGives() and initializeIdentity()
|
||||
*/
|
||||
async updateAllFeed() {
|
||||
this.isFeedLoading = true;
|
||||
@@ -754,6 +834,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Processes feed results and adds them to feedData
|
||||
*
|
||||
* @internal
|
||||
* Called by updateAllFeed()
|
||||
*/
|
||||
private async processFeedResults(records: GiveSummaryRecord[]) {
|
||||
for (const record of records) {
|
||||
@@ -767,6 +850,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Processes a single record and returns it if it passes filters
|
||||
*
|
||||
* @internal
|
||||
* Called by processFeedResults()
|
||||
*/
|
||||
private async processRecord(record: GiveSummaryRecord): Promise<GiveRecordWithContactInfo | null> {
|
||||
const claim = this.extractClaim(record);
|
||||
@@ -786,6 +872,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Extracts claim from record, handling both direct and wrapped claims
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private extractClaim(record: GiveSummaryRecord) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -794,6 +883,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Extracts giver DID from claim
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private extractGiverDid(claim: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -802,6 +894,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Extracts recipient DID from claim
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private extractRecipientDid(claim: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -810,6 +905,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Gets fulfills plan from cache
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private async getFulfillsPlan(record: GiveSummaryRecord) {
|
||||
return await getPlanFromCache(
|
||||
@@ -822,6 +920,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Checks if record should be included based on filters
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private shouldIncludeRecord(record: GiveSummaryRecord, fulfillsPlan: any): boolean {
|
||||
if (!this.isAnyFeedFilterOn) {
|
||||
@@ -844,6 +945,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Extracts provider from claim
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private extractProvider(claim: any) {
|
||||
return Array.isArray(claim.provider) ? claim.provider[0] : claim.provider;
|
||||
@@ -851,6 +955,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Gets provided by plan from cache
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private async getProvidedByPlan(provider: any) {
|
||||
return await getPlanFromCache(
|
||||
@@ -863,6 +970,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Creates a feed record with contact info
|
||||
*
|
||||
* @internal
|
||||
* Called by processRecord()
|
||||
*/
|
||||
private createFeedRecord(
|
||||
record: GiveSummaryRecord,
|
||||
@@ -908,6 +1018,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Updates the last viewed claim ID in settings
|
||||
*
|
||||
* @internal
|
||||
* Called by updateAllFeed()
|
||||
*/
|
||||
private async updateFeedLastViewedId(records: GiveSummaryRecord[]) {
|
||||
if (
|
||||
@@ -923,6 +1036,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Handles feed error and shows notification
|
||||
*
|
||||
* @internal
|
||||
* Called by updateAllFeed()
|
||||
*/
|
||||
private handleFeedError(e: any) {
|
||||
logger.error("Error with feed load:", e);
|
||||
@@ -939,9 +1055,12 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Retrieve claims in reverse chronological order
|
||||
*
|
||||
* @param beforeId the earliest ID (of previous searches) to search earlier
|
||||
* @return claims in reverse chronological order
|
||||
*
|
||||
* @internal
|
||||
* Called by updateAllFeed()
|
||||
* @param endorserApiServer API server URL
|
||||
* @param beforeId OptioCalled by updateAllFeed()nal ID to fetch earlier results
|
||||
* @returns claims in reverse chronological order
|
||||
*/
|
||||
async retrieveGives(endorserApiServer: string, beforeId?: string) {
|
||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||
@@ -974,6 +1093,14 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats gift description with giver and recipient info
|
||||
*
|
||||
* @public
|
||||
* Used in template for displaying gift descriptions
|
||||
* @param giveRecord Record containing gift information
|
||||
* @returns formatted description string
|
||||
*/
|
||||
giveDescription(giveRecord: GiveRecordWithContactInfo) {
|
||||
// similar code is in endorser-mobile utility.ts
|
||||
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||
@@ -1054,10 +1181,23 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to activity page
|
||||
*
|
||||
* @public
|
||||
* Called by template click handler
|
||||
*/
|
||||
goToActivityToUserPage() {
|
||||
this.$router.push({ name: "new-activity" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to claim details page
|
||||
*
|
||||
* @public
|
||||
* Called by ActivityListItem component
|
||||
* @param jwtId ID of the claim to view
|
||||
*/
|
||||
onClickLoadClaim(jwtId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
@@ -1065,16 +1205,31 @@ export default class HomeView extends Vue {
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats amount with currency code
|
||||
*
|
||||
* @internal
|
||||
* Called by giveDescription()
|
||||
*/
|
||||
displayAmount(code: string, amt: number) {
|
||||
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets currency word based on code and plurality
|
||||
*
|
||||
* @internal
|
||||
* Called by displayAmount()
|
||||
*/
|
||||
currencyShortWordForCode(unitCode: string, single: boolean) {
|
||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens dialog for creating new gift/claim
|
||||
*
|
||||
* @public
|
||||
* Called by template and openGiftedPrompts()
|
||||
* @param giver Optional contact info for giver
|
||||
* @param description Optional gift description
|
||||
*/
|
||||
@@ -1093,7 +1248,9 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Opens prompts for gift ideas
|
||||
* Links to openDialog for selected prompt
|
||||
*
|
||||
* @public
|
||||
* Called by template click handler
|
||||
*/
|
||||
openGiftedPrompts() {
|
||||
(this.$refs.giftedPrompts as GiftedPrompts).open((giver, description) =>
|
||||
@@ -1103,12 +1260,21 @@ export default class HomeView extends Vue {
|
||||
|
||||
/**
|
||||
* Opens feed filter configuration
|
||||
* @param reloadFeedOnChange Callback for when filters are updated
|
||||
*
|
||||
* @public
|
||||
* Called by template click handler
|
||||
*/
|
||||
openFeedFilters() {
|
||||
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows toast notification to user
|
||||
*
|
||||
* @internal
|
||||
* Used for various user notifications
|
||||
* @param message Message to display
|
||||
*/
|
||||
toastUser(message: string) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -1121,10 +1287,24 @@ export default class HomeView extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes CSS classes for known person icons
|
||||
*
|
||||
* @public
|
||||
* Used in template for icon styling
|
||||
* @param known Whether the person is known
|
||||
* @returns CSS class string
|
||||
*/
|
||||
computeKnownPersonIconStyleClassNames(known: boolean) {
|
||||
return known ? "text-slate-500" : "text-slate-100";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows name input dialog if needed
|
||||
*
|
||||
* @public
|
||||
* Called by template click handler
|
||||
*/
|
||||
showNameThenIdDialog() {
|
||||
if (!this.givenName) {
|
||||
(this.$refs.userNameDialog as UserNameDialog).open(() => {
|
||||
@@ -1135,6 +1315,12 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows dialog for sharing method selection
|
||||
*
|
||||
* @internal
|
||||
* Called by showNameThenIdDialog()
|
||||
*/
|
||||
promptForShareMethod() {
|
||||
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
|
||||
title: "How can you share your info?",
|
||||
@@ -1154,6 +1340,14 @@ export default class HomeView extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches image data for sharing
|
||||
*
|
||||
* @public
|
||||
* Called by ActivityListItem component
|
||||
* @param event Event object
|
||||
* @param imageUrl URL of image to cache
|
||||
*/
|
||||
async cacheImageData(event: Event, imageUrl: string) {
|
||||
try {
|
||||
// For images that might fail CORS, just store the URL
|
||||
@@ -1164,12 +1358,26 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens image viewer dialog
|
||||
*
|
||||
* @public
|
||||
* Called by ActivityListItem component
|
||||
* @param imageUrl URL of image to display
|
||||
*/
|
||||
async openImageViewer(imageUrl: string) {
|
||||
this.selectedImageData = this.imageCache.get(imageUrl) ?? null;
|
||||
this.selectedImage = imageUrl;
|
||||
this.isImageViewerOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles claim confirmation
|
||||
*
|
||||
* @public
|
||||
* Called by ActivityListItem component
|
||||
* @param record Record to confirm
|
||||
*/
|
||||
async confirmClaim(record: GiveRecordWithContactInfo) {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user