MM-39710: saved posts screen and DB (#6020)

* MM-39710: saved posts screen and DB

- Adds ids of saved posts to the systems table, as we do with recent
mentions.
- Adds a new remote action to fetch saved posts (getFlaggedPosts).
- Adds a new screen to display those in a mobile.
- Displays saved posts in the tablet view next to profile card.

* Uses Preferences instead of System table

Renames to saved posts wherever possible

* Adds text to localization file

* Fixes fetching/saving saved posts

* Refactor mini post to components folder

* Fixes hooks dependencies according to review

* Removes unnecessary 'withObservables'

* Small refactor

* Satisfies linter

* Adds empty state

And fixes empty state icon to be theme sensitive.
Both recent_mentions and saved_posts.

* Fixes empty screen's alignment

* Add missing preference

* add missing translation strings

* remove unused database type definition

* Fetch newly saved post

* Fix return type for client.getSavedPosts

* Remove usage of lodash compose

* Rename get remote actions to fetch

* Include close button for savedPost modal

* fix tablet view for SavePosts and use lottie loading indicator

* Render post with content for save posts and recent mentions

* post list viewable items type definition

* Add layout width to post content for saved post screen

* Use PostWithChannel and viewableItems for saved posts and recent mentions

* Layout margin of 20

* openGraphImage margin

* Fix openGraphImage display

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Kyriakos Z 2022-03-12 21:40:24 +02:00 committed by GitHub
parent 7e4b8b4dd9
commit 2645f7e66e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 790 additions and 234 deletions

View file

@ -2,6 +2,8 @@
// See LICENSE.txt for license information.
//
/* eslint-disable max-lines */
import {DeviceEventEmitter} from 'react-native';
import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel';
@ -459,7 +461,7 @@ export const postActionWithCookie = async (serverUrl: string, postId: string, ac
}
};
export async function getMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) {
export async function fetchMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@ -472,45 +474,50 @@ export async function getMissingChannelsFromPosts(serverUrl: string, posts: Post
return {error};
}
const channelIds = await queryAllMyChannelIds(operator.database);
const channelPromises: Array<Promise<Channel>> = [];
const userPromises: Array<Promise<ChannelMembership>> = [];
try {
const channelIds = await queryAllMyChannelIds(operator.database);
const channelPromises: Array<Promise<Channel>> = [];
const userPromises: Array<Promise<ChannelMembership>> = [];
posts.forEach((post) => {
const id = post.channel_id;
posts.forEach((post) => {
const id = post.channel_id;
if (channelIds.indexOf(id) === -1) {
channelPromises.push(client.getChannel(id));
userPromises.push(client.getMyChannelMember(id));
}
});
if (channelIds.indexOf(id) === -1) {
channelPromises.push(client.getChannel(id));
userPromises.push(client.getMyChannelMember(id));
}
});
const channels = await Promise.all(channelPromises);
const channelMemberships = await Promise.all(userPromises);
const channels = await Promise.all(channelPromises);
const channelMemberships = await Promise.all(userPromises);
if (!fetchOnly && channels.length && channelMemberships.length) {
const modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array<Promise<Model[]>>;
if (modelPromises && modelPromises.length) {
const channelModelsArray = await Promise.all(modelPromises);
if (channelModelsArray.length) {
const models = channelModelsArray.flatMap((mdls) => {
if (!mdls || mdls.length) {
return [];
if (!fetchOnly && channels.length && channelMemberships.length) {
const modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array<Promise<Model[]>>;
if (modelPromises && modelPromises.length) {
const channelModelsArray = await Promise.all(modelPromises);
if (channelModelsArray.length) {
const models = channelModelsArray.flatMap((mdls) => {
if (!mdls || mdls.length) {
return [];
}
return mdls;
});
if (models) {
operator.batchRecords(models);
}
return mdls;
});
if (models) {
operator.batchRecords(models);
}
}
}
}
return {
channels,
channelMemberships,
};
return {
channels,
channelMemberships,
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}
export const fetchPostById = async (serverUrl: string, postId: string, fetchOnly = false) => {
@ -529,7 +536,24 @@ export const fetchPostById = async (serverUrl: string, postId: string, fetchOnly
try {
const post = await client.getPost(postId);
if (!fetchOnly) {
operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_NEW, order: [post.id], posts: [post]});
const models: Model[] = [];
const {authors} = await fetchPostAuthors(serverUrl, [post], true);
const posts = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [post.id],
posts: [post],
prepareRecordsOnly: true,
});
models.push(...posts);
if (authors?.length) {
const users = await operator.handleUsers({
users: authors,
prepareRecordsOnly: false,
});
models.push(...users);
}
await operator.batchRecords(models);
}
return {post};
@ -641,3 +665,82 @@ export const markPostAsUnread = async (serverUrl: string, postId: string) => {
return {error};
}
};
export async function fetchSavedPosts(serverUrl: string, teamId?: string, channelId?: string, page?: number, perPage?: number) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const userId = await queryCurrentUserId(operator.database);
const data = await client.getSavedPosts(userId, channelId, teamId, page, perPage);
const posts = data.posts || {};
const order = data.order || [];
const postsArray = order.map((id) => posts[id]);
if (!postsArray.length) {
return {
order,
posts: postsArray,
};
}
const promises: Array<Promise<Model[]>> = [];
const {authors} = await fetchPostAuthors(serverUrl, postsArray, true);
const {channels, channelMemberships} = await fetchMissingChannelsFromPosts(serverUrl, postsArray, true) as {channels: Channel[]; channelMemberships: ChannelMembership[]};
if (authors?.length) {
promises.push(
operator.handleUsers({
users: authors,
prepareRecordsOnly: true,
}),
);
}
if (channels?.length && channelMemberships?.length) {
const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array<Promise<Model[]>>;
if (channelPromises && channelPromises.length) {
promises.push(...channelPromises);
}
}
promises.push(
operator.handlePosts({
actionType: '',
order: [],
posts: postsArray,
previousPostId: '',
prepareRecordsOnly: true,
}),
);
const modelArrays = await Promise.all(promises);
const models = modelArrays.flatMap((mdls) => {
if (!mdls || !mdls.length) {
return [];
}
return mdls;
});
if (models.length) {
await operator.batchRecords(models);
}
return {
order,
posts: postsArray,
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}

View file

@ -8,7 +8,7 @@ import NetworkManager from '@init/network_manager';
import {prepareMissingChannelsForAllTeams} from '@queries/servers/channel';
import {queryCurrentUser} from '@queries/servers/user';
import {fetchPostAuthors, getMissingChannelsFromPosts} from './post';
import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
@ -20,7 +20,7 @@ type PostSearchRequest = {
posts?: Post[];
}
export async function getRecentMentions(serverUrl: string): Promise<PostSearchRequest> {
export async function fetchRecentMentions(serverUrl: string): Promise<PostSearchRequest> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@ -67,7 +67,7 @@ export async function getRecentMentions(serverUrl: string): Promise<PostSearchRe
if (postsArray.length) {
const {authors} = await fetchPostAuthors(serverUrl, postsArray, true);
const {channels, channelMemberships} = await getMissingChannelsFromPosts(serverUrl, postsArray, true) as {channels: Channel[]; channelMemberships: ChannelMembership[]};
const {channels, channelMemberships} = await fetchMissingChannelsFromPosts(serverUrl, postsArray, true) as {channels: Channel[]; channelMemberships: ChannelMembership[]};
if (authors?.length) {
promises.push(

View file

@ -1,18 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchPostById} from '@actions/remote/post';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {queryPostById} from '@queries/servers/post';
import {deletePreferences} from '@queries/servers/preference';
export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
const database = DatabaseManager.serverDatabases[serverUrl];
if (!database) {
const operator = DatabaseManager.serverDatabases[serverUrl].operator;
if (!operator) {
return;
}
try {
const preference = JSON.parse(msg.data.preference) as PreferenceType;
const operator = database?.operator;
handleSavePostAdded(serverUrl, [preference]);
if (operator) {
operator.handlePreferences({
prepareRecordsOnly: false,
@ -25,14 +28,14 @@ export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSo
}
export async function handlePreferencesChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
const database = DatabaseManager.serverDatabases[serverUrl];
if (!database) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
}
try {
const preferences = JSON.parse(msg.data.preferences) as PreferenceType[];
const operator = database?.operator;
handleSavePostAdded(serverUrl, preferences);
if (operator) {
operator.handlePreferences({
prepareRecordsOnly: false,
@ -57,3 +60,19 @@ export async function handlePreferencesDeletedEvent(serverUrl: string, msg: WebS
// Do nothing
}
}
// If preferences include new save posts we fetch them
async function handleSavePostAdded(serverUrl: string, preferences: PreferenceType[]) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
const savedPosts = preferences.filter((p) => p.category === Preferences.CATEGORY_SAVED_POST);
for await (const saved of savedPosts) {
const post = await queryPostById(database, saved.name);
if (!post) {
await fetchPostById(serverUrl, saved.name, false);
}
}
}

View file

@ -17,7 +17,7 @@ export interface ClientPostsMix {
getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<PostResponse>;
getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<PostResponse>;
getFileInfosForPost: (postId: string) => Promise<FileInfo[]>;
getSavedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise<any>;
getSavedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise<PostResponse>;
getPinnedPosts: (channelId: string) => Promise<any>;
markPostAsUnread: (userId: string, postId: string) => Promise<any>;
pinPost: (postId: string) => Promise<any>;

View file

@ -45,6 +45,7 @@ type MarkdownProps = {
isEdited?: boolean;
isReplyPost?: boolean;
isSearchResult?: boolean;
layoutWidth?: number;
location?: string;
mentionKeys?: UserMentionKey[];
minimumHashtagLength?: number;
@ -64,6 +65,7 @@ class Markdown extends PureComponent<MarkdownProps> {
disableAtChannelMentionHighlight: false,
disableChannelLink: false,
disableGallery: false,
layoutWidth: undefined,
value: '',
minimumHashtagLength: 3,
};
@ -208,6 +210,7 @@ class Markdown extends PureComponent<MarkdownProps> {
<MarkdownImage
disabled={this.props.disableGallery ?? Boolean(!this.props.location)}
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}
layoutWidth={this.props.layoutWidth}
linkDestination={linkDestination}
imagesMetadata={this.props.imagesMetadata}
isReplyPost={this.props.isReplyPost}

View file

@ -36,6 +36,7 @@ type MarkdownImageProps = {
imagesMetadata: Record<string, PostImage>;
isReplyPost?: boolean;
linkDestination?: string;
layoutWidth?: number;
location?: string;
postId: string;
source: string;
@ -65,7 +66,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const MarkdownImage = ({
disabled, errorTextStyle, imagesMetadata, isReplyPost = false,
linkDestination, location, postId, source, sourceSize,
layoutWidth, linkDestination, location, postId, source, sourceSize,
}: MarkdownImageProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
@ -76,7 +77,7 @@ const MarkdownImage = ({
const tapRef = useRef<TapGestureHandler>();
const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})?.[0];
const [failed, setFailed] = useState(isGifTooLarge(metadata));
const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata);
const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata, layoutWidth);
const serverUrl = useServerUrl();
const galleryIdentifier = `${postId}-${genericFileId}-${location}`;
const uri = useMemo(() => {
@ -124,7 +125,7 @@ const MarkdownImage = ({
handlePreviewImage,
);
const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, getViewPortWidth(isReplyPost, isTablet));
const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet));
const handleLinkPress = useCallback(() => {
if (linkDestination) {

View file

@ -3,7 +3,7 @@
import {FlatList} from '@stream-io/flat-list-mvcp';
import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native';
import {DeviceEventEmitter, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import {fetchPosts, fetchPostThread} from '@actions/remote/post';
@ -21,6 +21,7 @@ import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} fro
import MoreMessages from './more_messages';
import PostListRefreshControl from './refresh_control';
import type {ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
@ -44,13 +45,7 @@ type Props = {
testID: string;
}
type ViewableItemsChanged = {
viewableItems: ViewToken[];
changed: ViewToken[];
}
type onScrollEndIndexListenerEvent = (endIndex: number) => void;
type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => void;
type ScrollIndexFailed = {
index: number;
@ -170,17 +165,17 @@ const PostList = ({
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
if (isViewable) {
acc[item.id] = true;
acc[`${location}-${item.id}`] = true;
}
return acc;
}, {});
DeviceEventEmitter.emit('scrolled', viewableItemsMap);
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap);
if (onViewableItemsChangedListener.current) {
onViewableItemsChangedListener.current(viewableItems);
}
}, []);
}, [location]);
const registerScrollEndIndexListener = useCallback((listener) => {
onScrollEndIndexListener.current = listener;
@ -285,7 +280,7 @@ const PostList = ({
}
// Skip rendering Flag for the root post in the thread as it is visible in the `Thread Overview`
const skipFlaggedHeader = (
const skipSaveddHeader = (
location === Screens.THREAD &&
item.id === rootId
);
@ -296,7 +291,7 @@ const PostList = ({
nextPost,
previousPost,
shouldRenderReplyButton,
skipFlaggedHeader,
skipSaveddHeader,
};
return (

View file

@ -33,6 +33,7 @@ type ImagePreviewProps = {
expandedLink?: string;
isReplyPost: boolean;
link: string;
layoutWidth?: number;
location: string;
metadata: PostMetadata;
postId: string;
@ -54,7 +55,7 @@ const style = StyleSheet.create({
},
});
const ImagePreview = ({expandedLink, isReplyPost, link, location, metadata, postId, theme}: ImagePreviewProps) => {
const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, metadata, postId, theme}: ImagePreviewProps) => {
const galleryIdentifier = `${postId}-ImagePreview-${location}`;
const [error, setError] = useState(false);
const serverUrl = useServerUrl();
@ -62,7 +63,7 @@ const ImagePreview = ({expandedLink, isReplyPost, link, location, metadata, post
const [imageUrl, setImageUrl] = useState(expandedLink || link);
const isTablet = useIsTablet();
const imageProps = metadata.images![link];
const dimensions = calculateDimensions(imageProps.height, imageProps.width, getViewPortWidth(isReplyPost, isTablet));
const dimensions = calculateDimensions(imageProps.height, imageProps.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet));
const onError = useCallback(() => {
setError(true);

View file

@ -15,6 +15,7 @@ import type PostModel from '@typings/database/models/servers/post';
type ContentProps = {
isReplyPost: boolean;
layoutWidth?: number;
location: string;
post: PostModel;
theme: Theme;
@ -28,7 +29,7 @@ const contentType: Record<string, string> = {
youtube: 'youtube',
};
const Content = ({isReplyPost, location, post, theme}: ContentProps) => {
const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps) => {
let type: string = post.metadata?.embeds?.[0].type as string;
if (!type && post.props?.attachments?.length) {
type = contentType.app_bindings;
@ -43,6 +44,7 @@ const Content = ({isReplyPost, location, post, theme}: ContentProps) => {
return (
<ImagePreview
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata!}
postId={post.id}
@ -54,6 +56,7 @@ const Content = ({isReplyPost, location, post, theme}: ContentProps) => {
return (
<YouTube
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
metadata={post.metadata!}
/>
);
@ -62,6 +65,7 @@ const Content = ({isReplyPost, location, post, theme}: ContentProps) => {
return (
<Opengraph
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata!}
postId={post.id}
@ -74,6 +78,7 @@ const Content = ({isReplyPost, location, post, theme}: ContentProps) => {
return (
<MessageAttachments
attachments={post.props.attachments}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata!}
postId={post.id}

View file

@ -45,17 +45,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
export type Props = {
imageMetadata: PostImage;
imageUrl: string;
layoutWidth?: number;
location: string;
postId: string;
theme: Theme;
}
const AttachmentImage = ({imageUrl, imageMetadata, location, postId, theme}: Props) => {
const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId, theme}: Props) => {
const galleryIdentifier = `${postId}-AttachmentImage-${location}`;
const [error, setError] = useState(false);
const fileId = useRef(generateId('uid')).current;
const isTablet = useIsTablet();
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, getViewPortWidth(false, isTablet));
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet));
const style = getStyleSheet(theme);
const onError = useCallback(() => {

View file

@ -8,6 +8,7 @@ import MessageAttachment from './message_attachment';
type Props = {
attachments: MessageAttachment[];
layoutWidth?: number;
location: string;
metadata?: PostMetadata;
postId: string;
@ -21,7 +22,7 @@ const styles = StyleSheet.create({
},
});
const MessageAttachments = ({attachments, location, metadata, postId, theme}: Props) => {
const MessageAttachments = ({attachments, layoutWidth, location, metadata, postId, theme}: Props) => {
const content = [] as React.ReactNode[];
attachments.forEach((attachment, i) => {
@ -29,6 +30,7 @@ const MessageAttachments = ({attachments, location, metadata, postId, theme}: Pr
<MessageAttachment
attachment={attachment}
key={'att_' + i.toString()}
layoutWidth={layoutWidth}
location={location}
metadata={metadata}
postId={postId}

View file

@ -21,6 +21,7 @@ import AttachmentTitle from './attachment_title';
type Props = {
attachment: MessageAttachment;
layoutWidth?: number;
location: string;
metadata?: PostMetadata;
postId: string;
@ -51,7 +52,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function MessageAttachment({attachment, location, metadata, postId, theme}: Props) {
export default function MessageAttachment({attachment, layoutWidth, location, metadata, postId, theme}: Props) {
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
@ -133,6 +134,7 @@ export default function MessageAttachment({attachment, location, metadata, postI
<AttachmentImage
imageUrl={attachment.image_url}
imageMetadata={metadata!.images![attachment.image_url]}
layoutWidth={layoutWidth}
location={location}
postId={postId}
theme={theme}

View file

@ -25,6 +25,7 @@ import type SystemModel from '@typings/database/models/servers/system';
type OpengraphProps = {
isReplyPost: boolean;
layoutWidth?: number;
location: string;
metadata: PostMetadata;
postId: string;
@ -71,7 +72,7 @@ const selectOpenGraphData = (url: string, metadata: PostMetadata) => {
})?.data;
};
const Opengraph = ({isReplyPost, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => {
const Opengraph = ({isReplyPost, layoutWidth, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => {
const intl = useIntl();
const link = metadata.embeds![0]!.url;
const openGraphData = selectOpenGraphData(link, metadata);
@ -163,6 +164,7 @@ const Opengraph = ({isReplyPost, location, metadata, postId, showLinkPreviews, t
{hasImage &&
<OpengraphImage
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
openGraphImages={openGraphData.images as never[]}
metadata={metadata}

View file

@ -20,6 +20,7 @@ import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
type OpengraphImageProps = {
isReplyPost: boolean;
layoutWidth?: number;
location: string;
metadata: PostMetadata;
openGraphImages: never[];
@ -54,7 +55,7 @@ const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidt
return viewPortWidth - tabletOffset;
};
const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => {
const fileId = useRef(generateId('uid')).current;
const dimensions = useWindowDimensions();
const style = getStyleSheet(theme);
@ -62,7 +63,7 @@ const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postI
const bestDimensions = useMemo(() => ({
height: MAX_IMAGE_HEIGHT,
width: getViewPostWidth(isReplyPost, dimensions.height, dimensions.width),
width: layoutWidth || getViewPostWidth(isReplyPost, dimensions.height, dimensions.width),
}), [isReplyPost, dimensions]);
const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height') as BestImage;
const imageUrl = (bestImage.secure_url || bestImage.url)!;
@ -85,7 +86,7 @@ const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postI
let imageDimensions = bestDimensions;
if (ogImage?.width && ogImage?.height) {
imageDimensions = calculateDimensions(ogImage.height, ogImage.width, getViewPostWidth(isReplyPost, dimensions.height, dimensions.width));
imageDimensions = calculateDimensions(ogImage.height, ogImage.width, (layoutWidth || getViewPostWidth(isReplyPost, dimensions.height, dimensions.width)) - 20);
}
const onPress = () => {

View file

@ -24,6 +24,7 @@ import type SystemModel from '@typings/database/models/servers/system';
type YouTubeProps = {
googleDeveloperKey?: string;
isReplyPost: boolean;
layoutWidth?: number;
metadata: PostMetadata;
}
@ -51,7 +52,7 @@ const styles = StyleSheet.create({
},
});
const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => {
const YouTube = ({googleDeveloperKey, isReplyPost, layoutWidth, metadata}: YouTubeProps) => {
const intl = useIntl();
const isTablet = useIsTablet();
const link = metadata.embeds![0].url;
@ -59,7 +60,7 @@ const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => {
const dimensions = calculateDimensions(
MAX_YOUTUBE_IMAGE_HEIGHT,
MAX_YOUTUBE_IMAGE_WIDTH,
getViewPortWidth(isReplyPost, isTablet),
layoutWidth || getViewPortWidth(isReplyPost, isTablet),
);
const getYouTubeTime = () => {

View file

@ -6,6 +6,7 @@ import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-
import Animated, {useDerivedValue} from 'react-native-reanimated';
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
import {Events} from '@constants';
import {GalleryInit} from '@context/gallery';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
@ -23,6 +24,7 @@ type FilesProps = {
canDownloadFiles: boolean;
failed?: boolean;
files: FileModel[];
layoutWidth?: number;
location: string;
isReplyPost: boolean;
postId: string;
@ -48,7 +50,7 @@ const styles = StyleSheet.create({
},
});
const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location, postId, publicLinkEnabled, theme}: FilesProps) => {
const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, layoutWidth, location, postId, publicLinkEnabled, theme}: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false);
const serverUrl = useServerUrl();
@ -130,7 +132,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location
nonVisibleImagesCount={nonVisibleImagesCount}
publicLinkEnabled={publicLinkEnabled}
updateFileForGallery={updateFileForGallery}
wrapperWidth={getViewPortWidth(isReplyPost, isTablet) - 15}
wrapperWidth={layoutWidth || (getViewPortWidth(isReplyPost, isTablet) - 15)}
inViewPort={inViewPort}
/>
</View>
@ -144,7 +146,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location
}
const visibleImages = imageAttachments.slice(0, MAX_VISIBLE_ROW_IMAGES);
const portraitPostWidth = getViewPortWidth(isReplyPost, isTablet) - 15;
const portraitPostWidth = layoutWidth || (getViewPortWidth(isReplyPost, isTablet) - 15);
let nonVisibleImagesCount;
if (imageAttachments.length > MAX_VISIBLE_ROW_IMAGES) {
@ -159,8 +161,8 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location
};
useEffect(() => {
const onScrollEnd = DeviceEventEmitter.addListener('scrolled', (viewableItems) => {
if (postId in viewableItems) {
const onScrollEnd = DeviceEventEmitter.addListener(Events.ITEM_IN_VIEWPORT, (viewableItems) => {
if (`${location}-${postId}` in viewableItems) {
setInViewPort(true);
}
});

View file

@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import React, {useCallback, useState} from 'react';
import {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import JumboEmoji from '@components/jumbo_emoji';
import {Screens} from '@constants';
import {THREAD} from '@constants/screens';
import {isEdited as postEdited} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme';
@ -78,6 +79,7 @@ const Body = ({
}: BodyProps) => {
const style = getStyleSheet(theme);
const isEdited = postEdited(post);
const [layoutWidth, setLayoutWidth] = useState(0);
const hasBeenDeleted = Boolean(post.deleteAt);
let body;
let message;
@ -107,6 +109,12 @@ const Body = ({
return barStyle;
}, []);
const onLayout = useCallback((e: LayoutChangeEvent) => {
if (location === Screens.SAVED_POSTS) {
setLayoutWidth(e.nativeEvent.layout.width);
}
}, [location]);
if (hasBeenDeleted) {
body = (
<FormattedText
@ -137,6 +145,7 @@ const Body = ({
isEdited={isEdited}
isPendingOrFailed={isPendingOrFailed}
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
post={post}
theme={theme}
@ -151,6 +160,7 @@ const Body = ({
{hasContent &&
<Content
isReplyPost={isReplyPost}
layoutWidth={layoutWidth}
location={location}
post={post}
theme={theme}
@ -160,6 +170,7 @@ const Body = ({
<Files
failed={post.props?.failed}
files={files}
layoutWidth={layoutWidth}
location={location}
post={post}
isReplyPost={isReplyPost}
@ -177,7 +188,10 @@ const Body = ({
}
return (
<View style={style.messageContainerWithReplyBar}>
<View
style={style.messageContainerWithReplyBar}
onLayout={onLayout}
>
<View style={replyBarStyle()}/>
{body}
{post.props?.failed &&

View file

@ -22,6 +22,7 @@ type MessageProps = {
isEdited: boolean;
isPendingOrFailed: boolean;
isReplyPost: boolean;
layoutWidth?: number;
location: string;
post: PostModel;
theme: Theme;
@ -49,7 +50,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPost, location, post, theme}: MessageProps) => {
const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, theme}: MessageProps) => {
const [open, setOpen] = useState(false);
const [height, setHeight] = useState<number|undefined>();
const dimensions = useWindowDimensions();
@ -87,6 +88,7 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo
isEdited={isEdited}
isReplyPost={isReplyPost}
isSearchResult={location === SEARCH}
layoutWidth={layoutWidth}
location={location}
postId={post.id}
textStyles={textStyles}

View file

@ -79,7 +79,7 @@ const Header = (props: HeaderProps) => {
const style = getStyleSheet(theme);
const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined;
const isReplyPost = Boolean(post.rootId && !isEphemeral);
const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0));
const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton && (!rootPostAuthor && commentCount > 0));
const displayName = postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride);
const rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser.locale, teammateNameDisplay, true) : undefined;
const customStatus = getUserCustomStatus(author);

View file

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {View, StyleSheet} from 'react-native';
import Post from '@components/post_list/post';
import ChannelInfo from './channel_info';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
post: PostModel;
location: string;
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 20,
},
content: {
flexDirection: 'row',
paddingBottom: 8,
},
});
function PostWithChannelInfo({post, location}: Props) {
return (
<View style={styles.container}>
<ChannelInfo post={post}/>
<View style={styles.content}>
<Post
post={post}
location={location}
highlightPinnedOrSaved={false}
skipPinnedHeader={true}
skipSavedHeader={true}
shouldRenderReplyButton={false}
showAddReaction={false}
previousPost={undefined}
nextPost={undefined}
/>
</View>
</View>
);
}
export default memo(PostWithChannelInfo);

View file

@ -11,7 +11,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
action?: string;
enabled?: boolean;
onPress: () => void;
onPress?: () => void;
title: string;
testID: string;
}

View file

@ -21,4 +21,5 @@ export default keyMirror({
USER_STOP_TYPING: null,
POST_LIST_SCROLL_TO_BOTTOM: null,
SWIPEABLE: null,
ITEM_IN_VIEWPORT: null,
});

View file

@ -30,6 +30,7 @@ const Preferences: Record<string, any> = {
DISPLAY_PREFER_FULL_NAME: 'full_name',
DISPLAY_PREFER_USERNAME: 'username',
EMOJI_SKINTONE: 'emoji_skintone',
LINK_PREVIEW_DISPLAY: 'link_previews',
MENTION_KEYS: 'mention_keys',
USE_MILITARY_TIME: 'use_military_time',
CATEGORY_SIDEBAR_SETTINGS: 'sidebar_settings',

View file

@ -32,6 +32,7 @@ export const SSO = 'SSO';
export const THREAD = 'Thread';
export const USER_PROFILE = 'UserProfile';
export const POST_OPTIONS = 'PostOptions';
export const SAVED_POSTS = 'SavedPosts';
export default {
ABOUT,
@ -65,4 +66,5 @@ export default {
THREAD,
USER_PROFILE,
POST_OPTIONS,
SAVED_POSTS,
};

View file

@ -2,10 +2,14 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {TextStyle} from 'react-native';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, TextStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import MenuItem from '@components/menu_item';
import {Events, Screens} from '@constants';
import {showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
type Props = {
@ -15,8 +19,29 @@ type Props = {
}
const SavedMessages = ({isTablet, style, theme}: Props) => {
const intl = useIntl();
const openSavedMessages = useCallback(preventDoubleTap(() => {
// TODO: Open Saved messages screen in either a screen or in line for tablets
if (isTablet) {
DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, Screens.SAVED_POSTS);
} else {
const closeButtonId = 'close-saved-posts';
const icon = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor);
const options = {
topBar: {
leftButtons: [{
id: closeButtonId,
testID: closeButtonId,
icon,
}],
},
};
showModal(
Screens.SAVED_POSTS,
intl.formatMessage({id: 'mobile.screen.saved_posts', defaultMessage: 'Saved Messages'}),
{closeButtonId},
options,
);
}
}), [isTablet]);
return (

View file

@ -7,6 +7,7 @@ import {DeviceEventEmitter} from 'react-native';
import {Events, Screens} from '@constants';
import CustomStatus from '@screens/custom_status';
import EditProfile from '@screens/edit_profile';
import SavedPosts from '@screens/home/saved_posts';
type SelectedView = {
id: string;
@ -16,6 +17,7 @@ type SelectedView = {
const TabletView: Record<string, React.ReactNode> = {
[Screens.CUSTOM_STATUS]: CustomStatus,
[Screens.EDIT_PROFILE]: EditProfile,
[Screens.SAVED_POSTS]: SavedPosts,
};
const AccountTabletView = () => {

View file

@ -8,8 +8,7 @@ import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
// @ts-expect-error svg extension
import Mention from './mention_icon.svg';
import Mention from './mention_icon';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
container: {

View file

@ -1,12 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import Mention from './mention';
const enhance = withObservables(['post'], ({post}) => ({
post,
}));
export default enhance(Mention);

View file

@ -1,111 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, StyleSheet} from 'react-native';
import Avatar from '@components/post_list/post/avatar';
import Message from '@components/post_list/post/body/message';
import Header from '@components/post_list/post/header';
import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {fromAutoResponder, isFromWebhook, isSystemMessage, isEdited as postEdited} from '@utils/post';
import ChannelInfo from '../channel_info';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUser: UserModel;
post: PostModel;
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 20,
},
content: {
flexDirection: 'row',
paddingBottom: 8,
},
message: {
flex: 1,
},
profilePictureContainer: {
marginBottom: 5,
marginRight: 10,
marginTop: 10,
},
});
function Mention({post, currentUser}: Props) {
const theme = useTheme();
const isAutoResponder = fromAutoResponder(post);
const isSystemPost = isSystemMessage(post);
const isWebHook = isFromWebhook(post);
const isEdited = postEdited(post);
const postAvatar = (
<View style={[styles.profilePictureContainer]}>
{isAutoResponder ? (
<SystemAvatar theme={theme}/>
) : (
<Avatar
isAutoReponse={isAutoResponder}
isSystemPost={isSystemPost}
post={post}
/>
)}
</View>
);
const header = isSystemPost && !isAutoResponder ? (
<SystemHeader
createAt={post.createAt}
theme={theme}
/>
) : (
<Header
currentUser={currentUser}
isAutoResponse={isAutoResponder}
differentThreadSequence={true}
isEphemeral={false}
isPendingOrFailed={false}
isSystemPost={isSystemPost}
isWebHook={isWebHook}
location={Screens.MENTIONS}
post={post}
shouldRenderReplyButton={false}
/>
);
return (
<View style={styles.container}>
<ChannelInfo post={post}/>
<View style={styles.content}>
{postAvatar}
<View>
{header}
<View style={styles.message}>
<Message
highlight={false}
isEdited={isEdited}
isPendingOrFailed={false}
isReplyPost={false}
location={Screens.MENTIONS}
post={post}
theme={theme}
/>
</View>
</View>
</View>
</View>
);
}
export default Mention;

View file

@ -1,10 +0,0 @@
<svg width="155" height="153" viewBox="0 0 155 153" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="80.5" cy="133" rx="54.5" ry="3" fill="black" fill-opacity="0.06"/>
<path d="M70.27 59.7713H129.709C130.798 59.7676 131.878 59.9781 132.885 60.3906C133.893 60.8032 134.81 61.4099 135.583 62.1759C136.355 62.9419 136.97 63.8524 137.39 64.8552C137.81 65.8581 138.028 66.9338 138.032 68.0208V105.718C138.028 106.805 137.81 107.88 137.39 108.883C136.97 109.886 136.355 110.797 135.583 111.563C134.81 112.329 133.893 112.935 132.885 113.348C131.878 113.76 130.798 113.971 129.709 113.967H120.937V128.078L107.779 113.967H70.2911C69.2018 113.971 68.1224 113.76 67.1146 113.348C66.1068 112.935 65.1904 112.329 64.4175 111.563C63.6447 110.797 63.0306 109.886 62.6103 108.883C62.1901 107.88 61.9719 106.805 61.9682 105.718V68.0208C61.9756 65.8291 62.8538 63.7298 64.4101 62.1833C65.9664 60.6368 68.0737 59.7694 70.27 59.7713Z" fill="#1E325C"/>
<path d="M107.779 113.967H70.291C69.2017 113.971 68.1224 113.76 67.1146 113.348C66.1068 112.935 65.1903 112.329 64.4175 111.563C63.6446 110.797 63.0305 109.886 62.6103 108.883C62.19 107.88 61.9718 106.805 61.9681 105.718V82.832C61.9681 82.832 64.5857 103.995 65.0559 105.858C65.526 107.721 66.4594 110.508 70.8805 110.97C75.3016 111.432 107.779 113.967 107.779 113.967Z" fill="#0A111F"/>
<path d="M131.863 75.5561C131.259 73.4228 130.238 71.4298 128.859 69.6919C127.48 67.954 125.77 66.5055 123.828 65.4298C123.713 65.3717 123.621 65.2773 123.565 65.1611C123.51 65.045 123.494 64.9138 123.522 64.788C123.549 64.6623 123.617 64.5492 123.716 64.4663C123.815 64.3835 123.938 64.3357 124.067 64.3303C127.814 64.1062 135.379 64.9046 133.007 75.5001C132.982 75.6294 132.914 75.7466 132.815 75.8329C132.715 75.9192 132.589 75.9697 132.457 75.9761C132.325 75.9826 132.195 75.9447 132.088 75.8685C131.98 75.7924 131.901 75.6823 131.863 75.5561Z" fill="#32539A"/>
<path d="M107.838 26.4362L32.1885 26.4362C30.8021 26.4315 29.4284 26.6994 28.1458 27.2245C26.8632 27.7496 25.6967 28.5217 24.7131 29.4966C23.7295 30.4716 22.9479 31.6303 22.413 32.9067C21.8782 34.1831 21.6004 35.5521 21.5958 36.9356V84.9135C21.6004 86.297 21.8782 87.666 22.413 88.9424C22.9479 90.2188 23.7295 91.3775 24.7131 92.3524C25.6967 93.3274 26.8632 94.0995 28.1458 94.6246C29.4284 95.1497 30.8021 95.4175 32.1885 95.4129H43.3529V113.372L60.0994 95.4129H107.811C109.198 95.4175 110.572 95.1497 111.854 94.6246C113.137 94.0995 114.303 93.3274 115.287 92.3524C116.27 91.3775 117.052 90.2188 117.587 88.9424C118.122 87.666 118.399 86.297 118.404 84.9135V36.9356C118.395 34.1462 117.277 31.4743 115.296 29.5061C113.316 27.5378 110.633 26.4338 107.838 26.4362Z" fill="#1C58D9"/>
<path d="M60.0995 95.4128H107.811C109.198 95.4175 110.572 95.1496 111.854 94.6245C113.137 94.0994 114.303 93.3273 115.287 92.3524C116.271 91.3774 117.052 90.2187 117.587 88.9423C118.122 87.6659 118.4 86.2969 118.404 84.9134V55.7862C118.404 55.7862 115.073 82.7209 114.474 85.0917C113.876 87.4625 112.688 91.0098 107.061 91.5981C101.434 92.1863 60.0995 95.4128 60.0995 95.4128Z" fill="black" fill-opacity="0.16"/>
<path d="M29.4467 46.5259C30.2154 43.8109 31.5147 41.2743 33.2698 39.0625C35.025 36.8506 37.2012 35.007 39.6733 33.6379C39.8197 33.5641 39.9374 33.4438 40.008 33.296C40.0786 33.1482 40.0981 32.9812 40.0633 32.8212C40.0286 32.6612 39.9417 32.5171 39.8161 32.4117C39.6906 32.3063 39.5336 32.2454 39.3696 32.2386C34.6002 31.9534 24.9721 32.9695 27.9909 46.4546C28.0228 46.6192 28.109 46.7684 28.2359 46.8783C28.3628 46.9881 28.523 47.0523 28.6908 47.0605C28.8586 47.0687 29.0243 47.0205 29.1613 46.9236C29.2984 46.8267 29.3989 46.6866 29.4467 46.5259Z" fill="white" fill-opacity="0.16"/>
<path d="M70.3926 66.9654C72.0377 66.9654 73.4981 66.3443 74.7739 65.1021C76.0496 63.8263 76.6875 62.3491 76.6875 60.6704C76.6875 58.9582 76.0664 57.481 74.8242 56.2388C73.582 54.9965 72.1048 54.3754 70.3926 54.3754C68.7139 54.3754 67.2367 55.0133 65.9609 56.2891C64.7187 57.5649 64.0976 59.0253 64.0976 60.6704C64.0976 62.2819 64.7187 63.7591 65.9609 65.1021C67.3038 66.3443 68.7811 66.9654 70.3926 66.9654ZM70.3926 39.6704C76.1336 39.6704 81.0688 41.7352 85.1983 45.8647C89.3278 49.9941 91.3926 54.9294 91.3926 60.6704V63.692C91.3926 65.7064 90.6875 67.5025 89.2775 69.0805C87.8674 70.457 86.1216 71.1452 84.0401 71.1452C81.5221 71.1452 79.4573 70.1045 77.8458 68.0229C75.7643 70.1045 73.2967 71.1452 70.4429 71.1452C67.5892 71.1452 65.1216 70.1045 63.0401 68.0229C60.9585 65.9414 59.9178 63.4906 59.9178 60.6704C59.9178 57.8503 60.9417 55.3659 62.9897 53.2172C65.0712 51.2028 67.5389 50.1956 70.3926 50.1956C73.2463 50.1956 75.7307 51.2028 77.8458 53.2172C79.8602 55.3323 80.8674 57.8167 80.8674 60.6704V63.692C80.8674 64.5313 81.1863 65.2867 81.8242 65.9582C82.4621 66.6296 83.2007 66.9654 84.0401 66.9654C84.913 66.9654 85.6516 66.6464 86.2559 66.0085C86.8602 65.3707 87.1624 64.5985 87.1624 63.692V60.6704C87.1624 56.0373 85.5173 52.0925 82.2271 48.8359C78.9705 45.5457 75.0257 43.9006 70.3926 43.9006C65.7595 43.9006 61.7979 45.5457 58.5077 48.8359C55.2511 52.0925 53.6228 56.0373 53.6228 60.6704C53.6228 65.3035 55.2511 69.2651 58.5077 72.5553C61.7979 75.8119 65.7595 77.4402 70.3926 77.4402H80.8674V81.6704H70.3926C64.7187 81.6704 59.8003 79.5889 55.6372 75.4258C51.4741 71.2627 49.3926 66.3275 49.3926 60.6201C49.3926 54.9126 51.4741 49.9941 55.6372 45.8647C59.8003 41.7352 64.7187 39.6704 70.3926 39.6704Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ViewStyle} from 'react-native';
import Svg, {Path, Ellipse} from 'react-native-svg';
import {useTheme} from '@context/theme';
type Props = {
style: ViewStyle;
}
function MentionIcon({style}: Props) {
const theme = useTheme();
return (
<Svg
width={155}
height={153}
fill='none'
style={style}
>
<Ellipse
cx={80.5}
cy={133}
rx={54.5}
ry={3}
fill='#000'
fillOpacity={0.06}
/>
<Path
d='M70.27 59.771h59.439a8.307 8.307 0 0 1 5.874 2.405 8.274 8.274 0 0 1 2.449 5.845v37.697a8.271 8.271 0 0 1-5.147 7.63 8.31 8.31 0 0 1-3.176.619h-8.772v14.111l-13.158-14.111H70.291a8.315 8.315 0 0 1-5.873-2.404 8.279 8.279 0 0 1-2.45-5.845V68.021a8.269 8.269 0 0 1 2.442-5.838 8.303 8.303 0 0 1 5.86-2.412Z'
fill='#1E325C'
/>
<Path
d='M107.779 113.967H70.291a8.315 8.315 0 0 1-5.873-2.404 8.276 8.276 0 0 1-2.45-5.845V82.832s2.618 21.163 3.088 23.026c.47 1.863 1.403 4.65 5.825 5.112 4.42.462 36.898 2.997 36.898 2.997Z'
fill='#0A111F'
/>
<Path
d='M131.863 75.556a16.796 16.796 0 0 0-3.004-5.864 16.836 16.836 0 0 0-5.031-4.262.585.585 0 0 1-.306-.642.577.577 0 0 1 .545-.458c3.747-.224 11.312.575 8.94 11.17a.591.591 0 0 1-1.144.056Z'
fill='#32539A'
/>
<Path
d='M107.838 26.436h-75.65a10.576 10.576 0 0 0-7.475 3.06 10.535 10.535 0 0 0-3.117 7.44v47.977a10.516 10.516 0 0 0 3.117 7.44 10.558 10.558 0 0 0 7.475 3.06h11.165v17.959l16.746-17.96h47.712a10.575 10.575 0 0 0 7.476-3.06 10.52 10.52 0 0 0 3.117-7.439V36.937a10.521 10.521 0 0 0-3.108-7.43 10.567 10.567 0 0 0-7.458-3.07Z'
fill={theme.buttonBg}
/>
<Path
d='M60.1 95.413h47.711a10.573 10.573 0 0 0 7.476-3.06 10.53 10.53 0 0 0 3.117-7.44V55.786s-3.331 26.935-3.93 29.306c-.598 2.37-1.786 5.918-7.413 6.506-5.627.588-46.962 3.815-46.962 3.815Z'
fill='#000'
fillOpacity={0.16}
/>
<Path
d='M29.447 46.526a21.375 21.375 0 0 1 3.823-7.464 21.426 21.426 0 0 1 6.403-5.424.74.74 0 0 0-.303-1.4c-4.77-.285-14.398.731-11.38 14.217a.749.749 0 0 0 1.171.469.748.748 0 0 0 .286-.398Z'
fill='#fff'
fillOpacity={0.16}
/>
<Path
d='M70.393 66.965c1.645 0 3.105-.62 4.38-1.863 1.277-1.276 1.915-2.753 1.915-4.432 0-1.712-.622-3.189-1.864-4.431s-2.72-1.864-4.431-1.864c-1.68 0-3.156.638-4.432 1.914-1.242 1.276-1.863 2.736-1.863 4.381 0 1.612.62 3.09 1.863 4.432 1.343 1.242 2.82 1.863 4.432 1.863Zm0-27.295c5.74 0 10.676 2.065 14.805 6.195 4.13 4.13 6.195 9.064 6.195 14.805v3.022c0 2.014-.706 3.81-2.115 5.389-1.41 1.376-3.156 2.064-5.238 2.064-2.518 0-4.583-1.04-6.194-3.122-2.082 2.082-4.55 3.122-7.403 3.122-2.854 0-5.321-1.04-7.403-3.122-2.081-2.082-3.122-4.532-3.122-7.353 0-2.82 1.024-5.304 3.072-7.453 2.081-2.014 4.549-3.021 7.403-3.021 2.853 0 5.338 1.007 7.453 3.021 2.014 2.115 3.021 4.6 3.021 7.453v3.022c0 .84.32 1.595.957 2.266.638.672 1.377 1.007 2.216 1.007.873 0 1.612-.319 2.216-.957.604-.637.906-1.41.906-2.316V60.67c0-4.633-1.645-8.578-4.935-11.834-3.257-3.29-7.201-4.935-11.834-4.935s-8.595 1.645-11.885 4.935c-3.257 3.257-4.885 7.201-4.885 11.834s1.628 8.595 4.885 11.885c3.29 3.257 7.252 4.885 11.885 4.885h10.474v4.23H70.393c-5.674 0-10.593-2.081-14.756-6.244s-6.244-9.099-6.244-14.806c0-5.707 2.081-10.626 6.244-14.755 4.163-4.13 9.082-6.195 14.756-6.195Z'
fill={theme.centerChannelBg}
/>
</Svg>
);
}
export default MentionIcon;

View file

@ -39,7 +39,6 @@ const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
).observe();
}),
),
currentUser,
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))),
isTimezoneEnabled: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')),

View file

@ -4,22 +4,23 @@
import {useIsFocused, useRoute} from '@react-navigation/native';
import React, {useCallback, useState, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, View, ActivityIndicator, FlatList} from 'react-native';
import {StyleSheet, View, ActivityIndicator, FlatList, DeviceEventEmitter} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {SafeAreaView, Edge} from 'react-native-safe-area-context';
import {getRecentMentions} from '@actions/remote/search';
import {fetchRecentMentions} from '@actions/remote/search';
import PostWithChannelInfo from '@app/components/post_with_channel_info';
import NavigationHeader from '@components/navigation_header';
import DateSeparator from '@components/post_list/date_separator';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {UserModel} from '@database/models/server';
import {useCollapsibleHeader} from '@hooks/header';
import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list';
import EmptyState from './components/empty';
import Mention from './components/mention';
import type {ViewableItemsChanged} from '@typings/components/post_list';
import type PostModel from '@typings/database/models/servers/post';
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
@ -28,7 +29,6 @@ const EDGES: Edge[] = ['bottom', 'left', 'right'];
type Props = {
currentTimezone: string | null;
currentUser: UserModel;
isTimezoneEnabled: boolean;
mentions: PostModel[];
}
@ -44,7 +44,7 @@ const styles = StyleSheet.create({
},
});
const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezoneEnabled}: Props) => {
const RecentMentionsScreen = ({mentions, currentTimezone, isTimezoneEnabled}: Props) => {
const theme = useTheme();
const route = useRoute();
const isFocused = useIsFocused();
@ -65,7 +65,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon
useEffect(() => {
setLoading(true);
getRecentMentions(serverUrl).finally(() => {
fetchRecentMentions(serverUrl).finally(() => {
setLoading(false);
});
}, [serverUrl]);
@ -78,7 +78,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await getRecentMentions(serverUrl);
await fetchRecentMentions(serverUrl);
setRefreshing(false);
}, [serverUrl]);
@ -94,6 +94,21 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon
};
}, []);
const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => {
if (!viewableItems.length) {
return;
}
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
if (isViewable) {
acc[`${Screens.MENTIONS}-${item.id}`] = true;
}
return acc;
}, {});
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap);
}, []);
const renderEmptyList = useCallback(() => (
<View style={[styles.empty, paddingTop]}>
{loading ? (
@ -105,7 +120,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon
<EmptyState/>
)}
</View>
), [loading]);
), [loading, theme, paddingTop]);
const renderItem = useCallback(({item}) => {
if (typeof item === 'string') {
@ -122,12 +137,12 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon
}
return (
<Mention
currentUser={currentUser}
<PostWithChannelInfo
location={Screens.MENTIONS}
post={item}
/>
);
}, [currentUser]);
}, []);
return (
<>
@ -159,6 +174,7 @@ const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezon
onRefresh={handleRefresh}
refreshing={refreshing}
renderItem={renderItem}
onViewableItemsChanged={onViewableItemsChanged}
/>
</Animated.View>
</SafeAreaView>

View file

@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import SavedPostsIcon from './saved_posts_icon';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 400),
},
paragraph: {
marginTop: 8,
textAlign: 'center',
color: changeOpacity(theme.centerChannelColor, 0.72),
...typography('Body', 200),
},
icon: {
alignItems: 'center',
justifyContent: 'center',
},
}));
function EmptySavedPosts() {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<SavedPostsIcon style={styles.icon}/>
<FormattedText
defaultMessage='No saved messages yet'
id='saved_posts.empty.title'
style={styles.title}
testID='saved_posts.empty.title'
/>
<FormattedText
defaultMessage={'To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.'}
id='saved_posts.empty.paragraph'
style={styles.paragraph}
testID='saved_posts.empty.paragraph'
/>
</View>
);
}
export default EmptySavedPosts;

View file

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ViewStyle} from 'react-native';
import Svg, {Path, Ellipse} from 'react-native-svg';
import {useTheme} from '@context/theme';
type Props = {
style: ViewStyle;
}
export default function SavedPostsIcon({style}: Props) {
const theme = useTheme();
return (
<Svg
width={155}
height={153}
fill='none'
style={style}
>
<Ellipse
cx={80.5}
cy={133}
rx={54.5}
ry={3}
fill='#000'
fillOpacity={0.06}
/>
<Path
d='M70.27 59.771h59.439a8.307 8.307 0 0 1 5.874 2.405 8.274 8.274 0 0 1 2.449 5.845v37.697a8.271 8.271 0 0 1-5.147 7.63 8.31 8.31 0 0 1-3.176.619h-8.772v14.111l-13.158-14.111H70.291a8.315 8.315 0 0 1-5.873-2.404 8.276 8.276 0 0 1-2.45-5.845V68.021a8.27 8.27 0 0 1 2.442-5.838 8.303 8.303 0 0 1 5.86-2.412Z'
fill={theme.buttonBg}
/>
<Path
d='M107.779 113.967H70.291a8.315 8.315 0 0 1-5.874-2.404 8.279 8.279 0 0 1-2.449-5.845V82.832s2.618 21.163 3.088 23.026c.47 1.863 1.403 4.65 5.824 5.112 4.422.462 36.899 2.997 36.899 2.997Z'
fill='#000'
fillOpacity={0.16}
/>
<Path
d='M131.863 75.556a16.796 16.796 0 0 0-3.004-5.864 16.836 16.836 0 0 0-5.031-4.262.585.585 0 0 1-.306-.642.577.577 0 0 1 .545-.458c3.747-.224 11.312.575 8.94 11.17a.591.591 0 0 1-1.144.056Z'
fill='#fff'
fillOpacity={0.16}
/>
<Path
d='M107.838 26.436h-75.65a10.577 10.577 0 0 0-7.475 3.06 10.533 10.533 0 0 0-3.117 7.44v47.977a10.516 10.516 0 0 0 3.117 7.44 10.56 10.56 0 0 0 7.475 3.06h11.165v17.959l16.746-17.96h47.712a10.578 10.578 0 0 0 7.476-3.06 10.52 10.52 0 0 0 3.117-7.439V36.937a10.521 10.521 0 0 0-3.108-7.43 10.567 10.567 0 0 0-7.458-3.07Z'
fill='#FFBC1F'
/>
<Path
d='M60.1 95.413h47.711a10.573 10.573 0 0 0 7.476-3.06 10.53 10.53 0 0 0 3.117-7.44V55.787s-3.331 26.935-3.93 29.306c-.598 2.37-1.786 5.918-7.413 6.506-5.627.588-46.962 3.815-46.962 3.815Z'
fill='#CC8F00'
/>
<Path
d='M29.447 46.526a21.375 21.375 0 0 1 3.823-7.464 21.426 21.426 0 0 1 6.403-5.424.74.74 0 0 0-.303-1.4c-4.77-.285-14.398.731-11.38 14.217a.749.749 0 0 0 1.171.469.748.748 0 0 0 .286-.398Z'
fill='#FFD470'
/>
<Path
d='M81.253 75.167 69.5 70.192l-11.753 4.975v-29.63h23.506v29.63Zm0-34.167H57.747c-1.319 0-2.45.456-3.39 1.367-.905.874-1.357 1.931-1.357 3.17V82l16.5-6.833L86 82V45.537c0-1.239-.47-2.296-1.413-3.17-.904-.911-2.015-1.367-3.334-1.367Z'
fill={theme.centerChannelBg}
/>
</Svg>
);
}

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@app/constants';
import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database';
import {SystemModel, UserModel, PreferenceModel} from '@database/models/server';
import {getTimezone} from '@utils/user';
import SavedMessagesScreen from './saved_posts';
import type {WithDatabaseArgs} from '@typings/database/database';
const {USER, SYSTEM, POST, PREFERENCE} = MM_TABLES.SERVER;
function getPostIDs(preferences: PreferenceModel[]) {
return preferences.map((preference) => preference.name);
}
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId) => database.get<UserModel>(USER).findAndObserve(currentUserId.value)),
);
return {
posts: database.get<PreferenceModel>(PREFERENCE).query(
Q.where('category', Preferences.CATEGORY_SAVED_POST),
Q.where('value', 'true'),
).observeWithColumns(['name']).pipe(
switchMap((rows) => {
if (!rows.length) {
return of$([]);
}
return of$(getPostIDs(rows));
}),
switchMap((ids) => {
return database.get(POST).query(
Q.where('id', Q.oneOf(ids)),
Q.sortBy('create_at', Q.asc),
).observe();
}),
),
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))),
isTimezoneEnabled: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')),
),
};
});
export default withDatabase(enhance(SavedMessagesScreen));

View file

@ -0,0 +1,180 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, FlatList, StyleSheet, View} from 'react-native';
import {EventSubscription, Navigation} from 'react-native-navigation';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {fetchSavedPosts} from '@actions/remote/post';
import Loading from '@components/loading';
import DateSeparator from '@components/post_list/date_separator';
import PostWithChannelInfo from '@components/post_with_channel_info';
import TabletTitle from '@components/tablet_title';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {dismissModal} from '@screens/navigation';
import {isDateLine, getDateForDateLine, selectOrderedPosts} from '@utils/post_list';
import EmptyState from './components/empty';
import type {ViewableItemsChanged} from '@typings/components/post_list';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
componentId?: string;
closeButtonId?: string;
currentTimezone: string | null;
isTimezoneEnabled: boolean;
isTablet?: boolean;
posts: PostModel[];
}
const edges: Edge[] = ['bottom', 'left', 'right'];
const styles = StyleSheet.create({
flex: {
flex: 1,
},
empty: {
alignItems: 'center',
minHeight: '100%',
justifyContent: 'center',
},
list: {
paddingVertical: 8,
},
loading: {
height: 40,
width: 40,
justifyContent: 'center' as const,
},
});
function SavedMessages({
componentId,
closeButtonId,
posts,
currentTimezone,
isTimezoneEnabled,
isTablet,
}: Props) {
const intl = useIntl();
const [loading, setLoading] = useState(!posts.length);
const [refreshing, setRefreshing] = useState(false);
const theme = useTheme();
const serverUrl = useServerUrl();
const data = useMemo(() => selectOrderedPosts(posts, 0, false, '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]);
useEffect(() => {
fetchSavedPosts(serverUrl).finally(() => {
setLoading(false);
});
}, []);
useEffect(() => {
let unsubscribe: EventSubscription | undefined;
if (componentId && closeButtonId) {
unsubscribe = Navigation.events().registerComponentListener({
navigationButtonPressed: ({buttonId}: { buttonId: string }) => {
switch (buttonId) {
case closeButtonId:
dismissModal({componentId});
break;
}
},
}, componentId);
}
return () => {
unsubscribe?.remove();
};
}, [componentId, closeButtonId]);
const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => {
if (!viewableItems.length) {
return;
}
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
if (isViewable) {
acc[`${Screens.SAVED_POSTS}-${item.id}`] = true;
}
return acc;
}, {});
DeviceEventEmitter.emit(Events.ITEM_IN_VIEWPORT, viewableItemsMap);
}, []);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await fetchSavedPosts(serverUrl);
setRefreshing(false);
}, [serverUrl]);
const emptyList = useMemo(() => (
<View style={styles.empty}>
{loading ? (
<Loading
color={theme.buttonBg}
style={styles.loading}
/>
) : (
<EmptyState/>
)}
</View>
), [loading, theme.centerChannelColor]);
const renderItem = useCallback(({item}) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
}
return (
<PostWithChannelInfo
location={Screens.SAVED_POSTS}
post={item}
/>
);
}, [currentTimezone, isTimezoneEnabled, theme]);
return (
<>
{isTablet &&
<TabletTitle
testID='custom_status.done.button'
title={intl.formatMessage({id: 'mobile.screen.saved_posts', defaultMessage: 'Saved Messages'})}
/>
}
<SafeAreaView
edges={edges}
style={styles.flex}
>
<FlatList
contentContainerStyle={styles.list}
ListEmptyComponent={emptyList}
data={data}
onRefresh={handleRefresh}
refreshing={refreshing}
renderItem={renderItem}
scrollToOverflowEnabled={true}
onViewableItemsChanged={onViewableItemsChanged}
/>
</SafeAreaView>
</>
);
}
export default SavedMessages;

View file

@ -110,6 +110,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.SSO:
screen = withIntl(require('@screens/sso').default);
break;
case Screens.SAVED_POSTS:
screen = withServerDatabase((require('@screens/home/saved_posts').default));
break;
case Screens.CREATE_DIRECT_MESSAGE:
screen = withServerDatabase((require('@screens/create_direct_message').default));
break;

View file

@ -24,7 +24,7 @@ const enhanced = withObservables(['post'], ({post, database}: WithDatabaseArgs &
const teamName = combineLatest([channel, currentTeamId]).pipe(
switchMap(([c, tid]) => {
const teamId = c.teamId || tid;
const teamId = c.teamId || tid.value;
return database.
get<TeamModel>(TEAM).
findAndObserve(teamId).

View file

@ -219,6 +219,7 @@ export const getMarkdownImageSize = (
isTablet: boolean,
sourceSize?: SourceSize,
knownSize?: PostImage,
layoutWidth?: number,
) => {
let ratioW;
let ratioH;
@ -253,6 +254,6 @@ export const getMarkdownImageSize = (
}
// When no metadata and source size is not specified (full size svg's)
const width = getViewPortWidth(isReplyPost, isTablet);
const width = layoutWidth || getViewPortWidth(isReplyPost, isTablet);
return {width, height: width};
};

View file

@ -284,7 +284,7 @@
"mobile.oauth.switch_to_browser.error_title": "Sign in error",
"mobile.oauth.switch_to_browser.title": "Redirecting...",
"mobile.oauth.try_again": "Try again",
"mobile.open_gm.error": "",
"mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.",
"mobile.permission_denied_dismiss": "Don't Allow",
"mobile.permission_denied_retry": "Settings",
"mobile.post_info.add_reaction": "Add Reaction",
@ -328,6 +328,7 @@
"mobile.routes.custom_status": "Set a Status",
"mobile.routes.table": "Table",
"mobile.routes.user_profile": "Profile",
"mobile.screen.saved_posts": "Saved Messages",
"mobile.screen.your_profile": "Your Profile",
"mobile.server_identifier.exists": "You are already connected to this server.",
"mobile.server_link.error.text": "The link could not be found on this server.",
@ -415,6 +416,8 @@
"post.options.title": "Options",
"posts_view.newMsg": "New Messages",
"public_link_copied": "Link copied to clipboard",
"saved_posts.empty.paragraph": "To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.",
"saved_posts.empty.title": "No saved messages yet",
"screen.mentions.subtitle": "Messages you've been mentioned in",
"screen.mentions.title": "Recent Mentions",
"screen.search.placeholder": "Search messages & files",

11
types/components/post_list.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ViewToken} from 'react-native';
export type ViewableItemsChanged = {
viewableItems: ViewToken[];
changed: ViewToken[];
}
export type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => void;