Remove post thread (#1920)
* Delete post-thread model * Remove post-thread-item * Remove unused typeszio/stable
parent
6616b2bff0
commit
d8b26edb56
|
@ -1,131 +0,0 @@
|
||||||
import {makeAutoObservable} from 'mobx'
|
|
||||||
import {
|
|
||||||
AppBskyFeedPost as FeedPost,
|
|
||||||
AppBskyFeedDefs,
|
|
||||||
RichText,
|
|
||||||
PostModeration,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {RootStoreModel} from '../root-store'
|
|
||||||
import {PostsFeedItemModel} from '../feeds/post'
|
|
||||||
|
|
||||||
type PostView = AppBskyFeedDefs.PostView
|
|
||||||
|
|
||||||
// NOTE: this model uses the same data as PostsFeedItemModel, but is used for
|
|
||||||
// rendering a single post in a thread view, and has additional state
|
|
||||||
// for rendering the thread view, but calls the same data methods
|
|
||||||
// as PostsFeedItemModel
|
|
||||||
// TODO: refactor as an extension or subclass of PostsFeedItemModel
|
|
||||||
export class PostThreadItemModel {
|
|
||||||
// ui state
|
|
||||||
_reactKey: string = ''
|
|
||||||
_depth = 0
|
|
||||||
_isHighlightedPost = false
|
|
||||||
_showParentReplyLine = false
|
|
||||||
_showChildReplyLine = false
|
|
||||||
_hasMore = false
|
|
||||||
|
|
||||||
// data
|
|
||||||
data: PostsFeedItemModel
|
|
||||||
post: PostView
|
|
||||||
postRecord?: FeedPost.Record
|
|
||||||
richText?: RichText
|
|
||||||
parent?:
|
|
||||||
| PostThreadItemModel
|
|
||||||
| AppBskyFeedDefs.NotFoundPost
|
|
||||||
| AppBskyFeedDefs.BlockedPost
|
|
||||||
replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public rootStore: RootStoreModel,
|
|
||||||
v: AppBskyFeedDefs.ThreadViewPost,
|
|
||||||
) {
|
|
||||||
this._reactKey = `thread-${v.post.uri}`
|
|
||||||
this.data = new PostsFeedItemModel(rootStore, this._reactKey, v)
|
|
||||||
this.post = this.data.post
|
|
||||||
this.postRecord = this.data.postRecord
|
|
||||||
this.richText = this.data.richText
|
|
||||||
// replies and parent are handled via assignTreeModels
|
|
||||||
makeAutoObservable(this, {rootStore: false})
|
|
||||||
}
|
|
||||||
|
|
||||||
get uri() {
|
|
||||||
return this.post.uri
|
|
||||||
}
|
|
||||||
|
|
||||||
get parentUri() {
|
|
||||||
return this.postRecord?.reply?.parent.uri
|
|
||||||
}
|
|
||||||
|
|
||||||
get rootUri(): string {
|
|
||||||
if (this.postRecord?.reply?.root.uri) {
|
|
||||||
return this.postRecord.reply.root.uri
|
|
||||||
}
|
|
||||||
return this.post.uri
|
|
||||||
}
|
|
||||||
|
|
||||||
get moderation(): PostModeration {
|
|
||||||
return this.data.moderation
|
|
||||||
}
|
|
||||||
|
|
||||||
assignTreeModels(
|
|
||||||
v: AppBskyFeedDefs.ThreadViewPost,
|
|
||||||
highlightedPostUri: string,
|
|
||||||
includeParent = true,
|
|
||||||
includeChildren = true,
|
|
||||||
) {
|
|
||||||
// parents
|
|
||||||
if (includeParent && v.parent) {
|
|
||||||
if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
|
|
||||||
const parentModel = new PostThreadItemModel(this.rootStore, v.parent)
|
|
||||||
parentModel._depth = this._depth - 1
|
|
||||||
parentModel._showChildReplyLine = true
|
|
||||||
if (v.parent.parent) {
|
|
||||||
parentModel._showParentReplyLine = true
|
|
||||||
parentModel.assignTreeModels(
|
|
||||||
v.parent,
|
|
||||||
highlightedPostUri,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.parent = parentModel
|
|
||||||
} else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
|
|
||||||
this.parent = v.parent
|
|
||||||
} else if (AppBskyFeedDefs.isBlockedPost(v.parent)) {
|
|
||||||
this.parent = v.parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// replies
|
|
||||||
if (includeChildren && v.replies) {
|
|
||||||
const replies = []
|
|
||||||
for (const item of v.replies) {
|
|
||||||
if (AppBskyFeedDefs.isThreadViewPost(item)) {
|
|
||||||
const itemModel = new PostThreadItemModel(this.rootStore, item)
|
|
||||||
itemModel._depth = this._depth + 1
|
|
||||||
itemModel._showParentReplyLine =
|
|
||||||
itemModel.parentUri !== highlightedPostUri
|
|
||||||
if (item.replies?.length) {
|
|
||||||
itemModel._showChildReplyLine = true
|
|
||||||
itemModel.assignTreeModels(item, highlightedPostUri, false, true)
|
|
||||||
}
|
|
||||||
replies.push(itemModel)
|
|
||||||
} else if (AppBskyFeedDefs.isNotFoundPost(item)) {
|
|
||||||
replies.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.replies = replies
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleLike() {
|
|
||||||
this.data.toggleLike()
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleRepost() {
|
|
||||||
this.data.toggleRepost()
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete() {
|
|
||||||
this.data.delete()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,342 +0,0 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
|
||||||
import {
|
|
||||||
AppBskyFeedGetPostThread as GetPostThread,
|
|
||||||
AppBskyFeedDefs,
|
|
||||||
AppBskyFeedPost,
|
|
||||||
PostModeration,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {AtUri} from '@atproto/api'
|
|
||||||
import {RootStoreModel} from '../root-store'
|
|
||||||
import * as apilib from 'lib/api/index'
|
|
||||||
import {cleanError} from 'lib/strings/errors'
|
|
||||||
import {ThreadViewPreference} from '../ui/preferences'
|
|
||||||
import {PostThreadItemModel} from './post-thread-item'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
|
|
||||||
export class PostThreadModel {
|
|
||||||
// state
|
|
||||||
isLoading = false
|
|
||||||
isLoadingFromCache = false
|
|
||||||
isFromCache = false
|
|
||||||
isRefreshing = false
|
|
||||||
hasLoaded = false
|
|
||||||
error = ''
|
|
||||||
notFound = false
|
|
||||||
resolvedUri = ''
|
|
||||||
params: GetPostThread.QueryParams
|
|
||||||
|
|
||||||
// data
|
|
||||||
thread?: PostThreadItemModel | null = null
|
|
||||||
isBlocked = false
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public rootStore: RootStoreModel,
|
|
||||||
params: GetPostThread.QueryParams,
|
|
||||||
) {
|
|
||||||
makeAutoObservable(
|
|
||||||
this,
|
|
||||||
{
|
|
||||||
rootStore: false,
|
|
||||||
params: false,
|
|
||||||
},
|
|
||||||
{autoBind: true},
|
|
||||||
)
|
|
||||||
this.params = params
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromPostView(
|
|
||||||
rootStore: RootStoreModel,
|
|
||||||
postView: AppBskyFeedDefs.PostView,
|
|
||||||
) {
|
|
||||||
const model = new PostThreadModel(rootStore, {uri: postView.uri})
|
|
||||||
model.resolvedUri = postView.uri
|
|
||||||
model.hasLoaded = true
|
|
||||||
model.thread = new PostThreadItemModel(rootStore, {
|
|
||||||
post: postView,
|
|
||||||
})
|
|
||||||
return model
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasContent() {
|
|
||||||
return !!this.thread
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasError() {
|
|
||||||
return this.error !== ''
|
|
||||||
}
|
|
||||||
|
|
||||||
get rootUri(): string {
|
|
||||||
if (this.thread) {
|
|
||||||
if (this.thread.postRecord?.reply?.root.uri) {
|
|
||||||
return this.thread.postRecord.reply.root.uri
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.resolvedUri
|
|
||||||
}
|
|
||||||
|
|
||||||
get isCachedPostAReply() {
|
|
||||||
if (AppBskyFeedPost.isRecord(this.thread?.post.record)) {
|
|
||||||
return !!this.thread?.post.record.reply
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// public api
|
|
||||||
// =
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load for first render
|
|
||||||
*/
|
|
||||||
async setup() {
|
|
||||||
if (!this.resolvedUri) {
|
|
||||||
await this._resolveUri()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.hasContent) {
|
|
||||||
await this.update()
|
|
||||||
} else {
|
|
||||||
const precache = this.rootStore.posts.cache.get(this.resolvedUri)
|
|
||||||
if (precache) {
|
|
||||||
await this._loadPrecached(precache)
|
|
||||||
} else {
|
|
||||||
await this._load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register any event listeners. Returns a cleanup function.
|
|
||||||
*/
|
|
||||||
registerListeners() {
|
|
||||||
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
|
|
||||||
return () => sub.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset and load
|
|
||||||
*/
|
|
||||||
async refresh() {
|
|
||||||
await this._load(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update content in-place
|
|
||||||
*/
|
|
||||||
async update() {
|
|
||||||
// NOTE: it currently seems that a full load-and-replace works fine for this
|
|
||||||
// if the UI loses its place or has jarring re-arrangements, replace this
|
|
||||||
// with a more in-place update
|
|
||||||
this._load()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes when posts are deleted
|
|
||||||
*/
|
|
||||||
onPostDeleted(_uri: string) {
|
|
||||||
this.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// state transitions
|
|
||||||
// =
|
|
||||||
|
|
||||||
_xLoading(isRefreshing = false) {
|
|
||||||
this.isLoading = true
|
|
||||||
this.isRefreshing = isRefreshing
|
|
||||||
this.error = ''
|
|
||||||
this.notFound = false
|
|
||||||
}
|
|
||||||
|
|
||||||
_xIdle(err?: any) {
|
|
||||||
this.isLoading = false
|
|
||||||
this.isRefreshing = false
|
|
||||||
this.hasLoaded = true
|
|
||||||
this.error = cleanError(err)
|
|
||||||
if (err) {
|
|
||||||
logger.error('Failed to fetch post thread', {error: err})
|
|
||||||
}
|
|
||||||
this.notFound = err instanceof GetPostThread.NotFoundError
|
|
||||||
}
|
|
||||||
|
|
||||||
// loader functions
|
|
||||||
// =
|
|
||||||
|
|
||||||
async _resolveUri() {
|
|
||||||
const urip = new AtUri(this.params.uri)
|
|
||||||
if (!urip.host.startsWith('did:')) {
|
|
||||||
try {
|
|
||||||
urip.host = await apilib.resolveName(this.rootStore, urip.host)
|
|
||||||
} catch (e: any) {
|
|
||||||
runInAction(() => {
|
|
||||||
this.error = e.toString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runInAction(() => {
|
|
||||||
this.resolvedUri = urip.toString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async _loadPrecached(precache: AppBskyFeedDefs.PostView) {
|
|
||||||
// start with the cached version
|
|
||||||
this.isLoadingFromCache = true
|
|
||||||
this.isFromCache = true
|
|
||||||
this._replaceAll({
|
|
||||||
success: true,
|
|
||||||
headers: {},
|
|
||||||
data: {
|
|
||||||
thread: {
|
|
||||||
post: precache,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this._xIdle()
|
|
||||||
|
|
||||||
// then update in the background
|
|
||||||
try {
|
|
||||||
const res = await this.rootStore.agent.getPostThread(
|
|
||||||
Object.assign({}, this.params, {uri: this.resolvedUri}),
|
|
||||||
)
|
|
||||||
this._replaceAll(res)
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(e)
|
|
||||||
this._xIdle(e)
|
|
||||||
} finally {
|
|
||||||
runInAction(() => {
|
|
||||||
this.isLoadingFromCache = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _load(isRefreshing = false) {
|
|
||||||
if (this.hasLoaded && !isRefreshing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this._xLoading(isRefreshing)
|
|
||||||
try {
|
|
||||||
const res = await this.rootStore.agent.getPostThread(
|
|
||||||
Object.assign({}, this.params, {uri: this.resolvedUri}),
|
|
||||||
)
|
|
||||||
this._replaceAll(res)
|
|
||||||
this._xIdle()
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(e)
|
|
||||||
this._xIdle(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_replaceAll(res: GetPostThread.Response) {
|
|
||||||
this.isBlocked = AppBskyFeedDefs.isBlockedPost(res.data.thread)
|
|
||||||
if (this.isBlocked) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pruneReplies(res.data.thread)
|
|
||||||
const thread = new PostThreadItemModel(
|
|
||||||
this.rootStore,
|
|
||||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
|
||||||
)
|
|
||||||
thread._isHighlightedPost = true
|
|
||||||
thread.assignTreeModels(
|
|
||||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
|
||||||
thread.uri,
|
|
||||||
)
|
|
||||||
sortThread(thread, this.rootStore.preferences.thread)
|
|
||||||
this.thread = thread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MaybePost =
|
|
||||||
| AppBskyFeedDefs.ThreadViewPost
|
|
||||||
| AppBskyFeedDefs.NotFoundPost
|
|
||||||
| AppBskyFeedDefs.BlockedPost
|
|
||||||
| {[k: string]: unknown; $type: string}
|
|
||||||
function pruneReplies(post: MaybePost) {
|
|
||||||
if (post.replies) {
|
|
||||||
post.replies = (post.replies as MaybePost[]).filter((reply: MaybePost) => {
|
|
||||||
if (reply.blocked) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
pruneReplies(reply)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MaybeThreadItem =
|
|
||||||
| PostThreadItemModel
|
|
||||||
| AppBskyFeedDefs.NotFoundPost
|
|
||||||
| AppBskyFeedDefs.BlockedPost
|
|
||||||
function sortThread(item: MaybeThreadItem, opts: ThreadViewPreference) {
|
|
||||||
if ('notFound' in item) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item = item as PostThreadItemModel
|
|
||||||
if (item.replies) {
|
|
||||||
item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => {
|
|
||||||
if ('notFound' in a && a.notFound) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if ('notFound' in b && b.notFound) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
item = item as PostThreadItemModel
|
|
||||||
a = a as PostThreadItemModel
|
|
||||||
b = b as PostThreadItemModel
|
|
||||||
const aIsByOp = a.post.author.did === item.post.author.did
|
|
||||||
const bIsByOp = b.post.author.did === item.post.author.did
|
|
||||||
if (aIsByOp && bIsByOp) {
|
|
||||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
|
||||||
} else if (aIsByOp) {
|
|
||||||
return -1 // op's own reply
|
|
||||||
} else if (bIsByOp) {
|
|
||||||
return 1 // op's own reply
|
|
||||||
}
|
|
||||||
// put moderated content down at the bottom
|
|
||||||
if (modScore(a.moderation) !== modScore(b.moderation)) {
|
|
||||||
return modScore(a.moderation) - modScore(b.moderation)
|
|
||||||
}
|
|
||||||
if (opts.prioritizeFollowedUsers) {
|
|
||||||
const af = a.post.author.viewer?.following
|
|
||||||
const bf = b.post.author.viewer?.following
|
|
||||||
if (af && !bf) {
|
|
||||||
return -1
|
|
||||||
} else if (!af && bf) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (opts.sort === 'oldest') {
|
|
||||||
return a.post.indexedAt.localeCompare(b.post.indexedAt)
|
|
||||||
} else if (opts.sort === 'newest') {
|
|
||||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
|
||||||
} else if (opts.sort === 'most-likes') {
|
|
||||||
if (a.post.likeCount === b.post.likeCount) {
|
|
||||||
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
|
|
||||||
} else {
|
|
||||||
return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
|
|
||||||
}
|
|
||||||
} else if (opts.sort === 'random') {
|
|
||||||
return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
|
|
||||||
}
|
|
||||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
|
||||||
})
|
|
||||||
item.replies.forEach(reply => sortThread(reply, opts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function modScore(mod: PostModeration): number {
|
|
||||||
if (mod.content.blur && mod.content.noOverride) {
|
|
||||||
return 5
|
|
||||||
}
|
|
||||||
if (mod.content.blur) {
|
|
||||||
return 4
|
|
||||||
}
|
|
||||||
if (mod.content.alert) {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
if (mod.embed.blur && mod.embed.noOverride) {
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
if (mod.embed.blur) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
|
@ -1,7 +1,6 @@
|
||||||
import {makeAutoObservable} from 'mobx'
|
import {makeAutoObservable} from 'mobx'
|
||||||
import {
|
import {
|
||||||
LabelPreference as APILabelPreference,
|
LabelPreference as APILabelPreference,
|
||||||
BskyFeedViewPreference,
|
|
||||||
BskyThreadViewPreference,
|
BskyThreadViewPreference,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
|
@ -10,9 +9,6 @@ import {ModerationOpts} from '@atproto/api'
|
||||||
|
|
||||||
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
||||||
export type LabelPreference = APILabelPreference | 'show'
|
export type LabelPreference = APILabelPreference | 'show'
|
||||||
export type FeedViewPreference = BskyFeedViewPreference & {
|
|
||||||
lab_mergeFeedEnabled?: boolean | undefined
|
|
||||||
}
|
|
||||||
export type ThreadViewPreference = BskyThreadViewPreference & {
|
export type ThreadViewPreference = BskyThreadViewPreference & {
|
||||||
lab_treeViewEnabled?: boolean | undefined
|
lab_treeViewEnabled?: boolean | undefined
|
||||||
}
|
}
|
||||||
|
@ -35,11 +31,6 @@ export class PreferencesModel {
|
||||||
contentLabels = new LabelPreferencesModel()
|
contentLabels = new LabelPreferencesModel()
|
||||||
savedFeeds: string[] = []
|
savedFeeds: string[] = []
|
||||||
pinnedFeeds: string[] = []
|
pinnedFeeds: string[] = []
|
||||||
thread: ThreadViewPreference = {
|
|
||||||
sort: 'oldest',
|
|
||||||
prioritizeFollowedUsers: true,
|
|
||||||
lab_treeViewEnabled: false, // experimental
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(this, {}, {autoBind: true})
|
makeAutoObservable(this, {}, {autoBind: true})
|
||||||
|
|
|
@ -4,8 +4,8 @@ import {
|
||||||
AppBskyFeedGetPostThread,
|
AppBskyFeedGetPostThread,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {useQuery} from '@tanstack/react-query'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
import {useSession} from '../session'
|
import {useSession} from '#/state/session'
|
||||||
import {ThreadViewPreference} from '../models/ui/preferences'
|
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||||
|
|
||||||
export const RQKEY = (uri: string) => ['post-thread', uri]
|
export const RQKEY = (uri: string) => ['post-thread', uri]
|
||||||
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
|
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
|
||||||
|
@ -72,7 +72,7 @@ export function usePostThreadQuery(uri: string | undefined) {
|
||||||
|
|
||||||
export function sortThread(
|
export function sortThread(
|
||||||
node: ThreadNode,
|
node: ThreadNode,
|
||||||
opts: ThreadViewPreference,
|
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
||||||
): ThreadNode {
|
): ThreadNode {
|
||||||
if (node.type !== 'post') {
|
if (node.type !== 'post') {
|
||||||
return node
|
return node
|
||||||
|
|
Loading…
Reference in New Issue