Merge pull request #126 from matrix-org/matrixtwo/groups

Add rudimentary group support
This commit is contained in:
Jorik Schellekens 2020-09-23 12:03:04 +01:00 committed by GitHub
commit dd372dbb50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 223 additions and 78 deletions

View File

@ -25,4 +25,5 @@ limitations under the License.
.avatarNoCrop { .avatarNoCrop {
border-radius: 0; border-radius: 0;
border: 0;
} }

View File

@ -16,7 +16,7 @@ limitations under the License.
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Room, User } from '../matrix-cypher'; import { Group, Room, User } from '../matrix-cypher';
import { getMediaQueryFromMCX } from '../utils/cypher-wrapper'; import { getMediaQueryFromMCX } from '../utils/cypher-wrapper';
import logo from '../imgs/chat-icon.svg'; import logo from '../imgs/chat-icon.svg';
@ -35,12 +35,15 @@ const Avatar: React.FC<IProps> = ({ className, avatarUrl, label }: IProps) => {
setSrc(avatarUrl); setSrc(avatarUrl);
}, [avatarUrl]); }, [avatarUrl]);
const _className = classNames('avatar', className, {
avatarNoCrop: src === logo,
});
return ( return (
<img <img
src={src} src={src}
onError={(): void => setSrc(logo)} onError={(): void => setSrc(logo)}
alt={label} alt={label}
className={classNames('avatar', className)} className={_className}
/> />
); );
}; };
@ -73,4 +76,17 @@ export const RoomAvatar: React.FC<IPropsRoomAvatar> = ({
/> />
); );
interface IPropsGroupAvatar {
group: Group;
}
export const GroupAvatar: React.FC<IPropsGroupAvatar> = ({
group,
}: IPropsGroupAvatar) => (
<Avatar
avatarUrl={getMediaQueryFromMCX(group.avatar_url)}
label={group.name}
/>
);
export default Avatar; export default Avatar;

View File

@ -0,0 +1,26 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.groupPreview {
> .avatar {
margin-bottom: 8px;
}
> h1 {
font-size: 24px;
margin-bottom: 4px;
}
}

View File

@ -0,0 +1,44 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { Group } from '../matrix-cypher';
import { GroupAvatar } from './Avatar';
import './GroupPreview.scss';
interface IProps {
group: Group;
}
const GroupPreview: React.FC<IProps> = ({ group }: IProps) => {
const description = group.long_description
? group.long_description
: group.short_description
? group.short_description
: null;
return (
<div className="groupPreview">
<GroupAvatar group={group} />
<h1>{group.name}</h1>
{description ? <p>{description}</p> : null}
</div>
);
};
export default GroupPreview;

View File

@ -22,6 +22,7 @@ import InviteTile from './InviteTile';
import { SafeLink, LinkKind } from '../parser/types'; import { SafeLink, LinkKind } from '../parser/types';
import UserPreview, { WrappedInviterPreview } from './UserPreview'; import UserPreview, { WrappedInviterPreview } from './UserPreview';
import EventPreview from './EventPreview'; import EventPreview from './EventPreview';
import GroupPreview from './GroupPreview';
import HomeserverOptions from './HomeserverOptions'; import HomeserverOptions from './HomeserverOptions';
import DefaultPreview from './DefaultPreview'; import DefaultPreview from './DefaultPreview';
import Toggle from './Toggle'; import Toggle from './Toggle';
@ -31,6 +32,7 @@ import {
getRoomFromAlias, getRoomFromAlias,
getRoomFromPermalink, getRoomFromPermalink,
getUser, getUser,
getGroup,
} from '../utils/cypher-wrapper'; } from '../utils/cypher-wrapper';
import { ClientContext } from '../contexts/ClientContext'; import { ClientContext } from '../contexts/ClientContext';
import useHSs from '../utils/getHS'; import useHSs from '../utils/getHS';
@ -86,6 +88,13 @@ const invite = async ({
/> />
); );
case LinkKind.GroupId:
return (
<GroupPreview
group={await getGroup(clientAddress, link.identifier)}
/>
);
default: default:
// Todo Implement events // Todo Implement events
return <></>; return <></>;

View File

@ -16,28 +16,24 @@ limitations under the License.
/* eslint-disable import/first */ /* eslint-disable import/first */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore // @ts-ignore
import any from 'promise.any'; import any from 'promise.any';
any.shim() any.shim();
import VersionSchema from './schemas/VersionSchema'; import VersionSchema from './schemas/VersionSchema';
import WellKnownSchema from './schemas/WellKnownSchema'; import WellKnownSchema from './schemas/WellKnownSchema';
import UserSchema, { User } from './schemas/UserSchema'; import UserSchema, { User } from './schemas/UserSchema';
import RoomAliasSchema, { import RoomAliasSchema, { RoomAlias } from './schemas/RoomAliasSchema';
RoomAlias,
} from './schemas/RoomAliasSchema';
import PublicRoomsSchema, { import PublicRoomsSchema, {
PublicRooms, PublicRooms,
Room, Room,
} from './schemas/PublicRoomsSchema'; } from './schemas/PublicRoomsSchema';
import EventSchema, { import EventSchema, { Event } from './schemas/EventSchema';
Event, import GroupSchema, { Group } from './schemas/GroupSchema';
} from './schemas/EventSchema';
import { ensure } from './utils/promises'; import { ensure } from './utils/promises';
import { prefixFetch, parseJSON } from './utils/fetch'; import { prefixFetch, parseJSON } from './utils/fetch';
/* /*
* A client is a resolved homeserver name wrapped in a lambda'd fetch * A client is a resolved homeserver name wrapped in a lambda'd fetch
*/ */
@ -46,7 +42,7 @@ export type Client = (path: string) => Promise<Response>;
/* /*
* Confirms that the target homeserver is properly configured and operational * Confirms that the target homeserver is properly configured and operational
*/ */
export const validateHS = (host: string) => export const validateHS = (host: string): Promise<string> =>
prefixFetch(host)('/_matrix/client/versions') prefixFetch(host)('/_matrix/client/versions')
.then(parseJSON) .then(parseJSON)
.then(VersionSchema.parse) .then(VersionSchema.parse)
@ -56,47 +52,43 @@ export const validateHS = (host: string) =>
* Discovers the correct domain name for the host according to the spec's * Discovers the correct domain name for the host according to the spec's
* discovery rules * discovery rules
*/ */
export const discoverServer = (host: string) => export const discoverServer = (host: string): Promise<string> =>
prefixFetch(host)('/.well-known/matrix/client') prefixFetch(host)('/.well-known/matrix/client')
.then(resp => resp.ok .then((resp) =>
? resp.json() resp.ok
.then(WellKnownSchema.parse) ? resp
.then(content => { .json()
if (content === undefined) return host; .then(WellKnownSchema.parse)
else if ( .then((content) => {
'm.homeserver' in content && content['m.homeserver'] if (content === undefined) return host;
) { else if (
return content['m.homeserver'].base_url 'm.homeserver' in content &&
} else { content['m.homeserver']
return host ) {
} return content['m.homeserver'].base_url;
}) } else {
: ensure( return host;
resp.status === 404, }
() => host, })
), : ensure(resp.status === 404, () => host)
) )
.then(validateHS) .then(validateHS);
/* /*
* Takes a hs domain and resolves it to it's current domain and returns a * Takes a hs domain and resolves it to it's current domain and returns a
* client * client
*/ */
export async function client(host: string): Promise<Client> { export async function client(host: string): Promise<Client> {
return prefixFetch(await discoverServer(host)) return prefixFetch(await discoverServer(host));
} }
/* /*
* Gets the details for a user * Gets the details for a user
*/ */
export function getUserDetails( export function getUserDetails(client: Client, userId: string): Promise<User> {
client: Client,
userId: string,
): Promise<User> {
return client(`/_matrix/client/r0/profile/${userId}`) return client(`/_matrix/client/r0/profile/${userId}`)
.then(parseJSON) .then(parseJSON)
.then(UserSchema.parse) .then(UserSchema.parse);
} }
/* /*
@ -104,7 +96,7 @@ export function getUserDetails(
*/ */
export function getRoomIdFromAlias( export function getRoomIdFromAlias(
client: Client, client: Client,
roomAlias: string, roomAlias: string
): Promise<RoomAlias> { ): Promise<RoomAlias> {
const encodedRoomAlias = encodeURIComponent(roomAlias); const encodedRoomAlias = encodeURIComponent(roomAlias);
return client(`/_matrix/client/r0/directory/room/${encodedRoomAlias}`) return client(`/_matrix/client/r0/directory/room/${encodedRoomAlias}`)
@ -112,23 +104,6 @@ export function getRoomIdFromAlias(
.then(RoomAliasSchema.parse); .then(RoomAliasSchema.parse);
} }
/*
* Gets the details of a room if that room is public
*/
export function getRoomDetails(clients: Client[], roomId: string): Promise<Room> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return Promise.any(clients.map(client => searchPublicRooms(client, roomId)));
}
/*
* Gets a list of all public rooms on a hs
*/
export function getPublicRooms(client: Client): Promise<PublicRooms> {
return getPublicRoomsUnsafe(client)
.then(PublicRoomsSchema.parse)
}
/* /*
* Similar to getPubliRooms however id doesn't confirm the data returned from * Similar to getPubliRooms however id doesn't confirm the data returned from
* the hs is correct * the hs is correct
@ -138,8 +113,14 @@ export function getPublicRooms(client: Client): Promise<PublicRooms> {
*/ */
export function getPublicRoomsUnsafe(client: Client): Promise<PublicRooms> { export function getPublicRoomsUnsafe(client: Client): Promise<PublicRooms> {
// TODO: Do not assume server will return all results in one go // TODO: Do not assume server will return all results in one go
return client('/_matrix/client/r0/publicRooms') return client('/_matrix/client/r0/publicRooms').then(parseJSON);
.then(parseJSON) }
/*
* Gets a list of all public rooms on a hs
*/
export function getPublicRooms(client: Client): Promise<PublicRooms> {
return getPublicRoomsUnsafe(client).then(PublicRoomsSchema.parse);
} }
/* /*
@ -147,20 +128,33 @@ export function getPublicRoomsUnsafe(client: Client): Promise<PublicRooms> {
*/ */
export function searchPublicRooms( export function searchPublicRooms(
client: Client, client: Client,
roomId: string, roomId: string
): Promise<Room> { ): Promise<Room> {
// we use the unsage version here because the safe one is sloooow // we use the unsage version here because the safe one is sloooow
return getPublicRoomsUnsafe(client) return getPublicRoomsUnsafe(client).then((rooms) => {
.then(rooms => { const [match] = rooms.chunk.filter((chunk) => chunk.room_id === roomId);
const [match] = rooms.chunk.filter( return match !== undefined
chunk => chunk.room_id === roomId, ? Promise.resolve(match)
); : Promise.reject(
return match !== undefined new Error(
? Promise.resolve(match) `This server knowns no public room with id ${roomId}`
: Promise.reject(new Error( )
`This server knowns no public room with id ${roomId}`, );
)); });
}); }
/*
* Gets the details of a room if that room is public
*/
export function getRoomDetails(
clients: Client[],
roomId: string
): Promise<Room> {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
return Promise.any(
clients.map((client) => searchPublicRooms(client, roomId))
);
} }
/* /*
@ -169,24 +163,33 @@ export function searchPublicRooms(
export async function getEvent( export async function getEvent(
client: Client, client: Client,
roomIdOrAlias: string, roomIdOrAlias: string,
eventId: string, eventId: string
): Promise<Event> { ): Promise<Event> {
return client(`/_matrix/client/r0/rooms/${roomIdOrAlias}/event/${eventId}`) return client(`/_matrix/client/r0/rooms/${roomIdOrAlias}/event/${eventId}`)
.then(parseJSON) .then(parseJSON)
.then(EventSchema.parse); .then(EventSchema.parse);
} }
/*
* Gets community information
*/
export async function getGroupDetails(
client: Client,
groupId: string
): Promise<Group> {
return client(`/_matrix/client/r0/groups/${groupId}/profile`)
.then(parseJSON)
.then(GroupSchema.parse);
}
/* /*
* Gets an mxc resource * Gets an mxc resource
*/ */
export function convertMXCtoMediaQuery( export function convertMXCtoMediaQuery(clientURL: string, mxc: string): string {
clientURL: string,
mxc: string,
): string {
// mxc://matrix.org/EqMZYbAYhREvHXvYFyfxOlkf // mxc://matrix.org/EqMZYbAYhREvHXvYFyfxOlkf
const matches = mxc.match(/mxc:\/\/(.+)\/(.+)/) const matches = mxc.match(/mxc:\/\/(.+)\/(.+)/);
if (!matches) { if (!matches) {
throw new Error(`mxc invalid: ${JSON.stringify({mxc})}`); throw new Error(`mxc invalid: ${JSON.stringify({ mxc })}`);
} }
return `${clientURL}/_matrix/media/r0/download/${matches[1]}/${matches[2]}`; return `${clientURL}/_matrix/media/r0/download/${matches[1]}/${matches[2]}`;

View File

@ -0,0 +1,27 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { object, string, TypeOf } from 'zod';
const GroupSchema = object({
name: string(),
avatar_url: string().optional(),
short_description: string().optional(),
long_description: string().optional(),
});
export type Group = TypeOf<typeof GroupSchema>;
export default GroupSchema;

View File

@ -15,10 +15,10 @@ limitations under the License.
*/ */
export * from './EventSchema'; export * from './EventSchema';
export * from './GroupSchema';
export * from './PublicRoomsSchema'; export * from './PublicRoomsSchema';
export * from './RoomAliasSchema'; export * from './RoomAliasSchema';
export * from './UserSchema'; export * from './UserSchema';
export * from './VersionSchema'; export * from './VersionSchema';
export * from './WellKnownSchema'; export * from './WellKnownSchema';
export * from './index'; export * from './index';

View File

@ -24,10 +24,12 @@ import {
Room, Room,
RoomAlias, RoomAlias,
User, User,
Group,
getRoomIdFromAlias, getRoomIdFromAlias,
searchPublicRooms, searchPublicRooms,
getUserDetails, getUserDetails,
convertMXCtoMediaQuery, convertMXCtoMediaQuery,
getGroupDetails,
} from '../matrix-cypher'; } from '../matrix-cypher';
import { LinkKind, Permalink } from '../parser/types'; import { LinkKind, Permalink } from '../parser/types';
@ -72,6 +74,11 @@ export const fallbackRoom = ({
}; };
}; };
export const fallbackGroup = (groupId: string): Group => ({
name: groupId,
short_description: `The ${groupId} group`,
});
/* /*
* Tries to fetch room details from an alias. If it fails it uses * Tries to fetch room details from an alias. If it fails it uses
* a `fallbackRoom` * a `fallbackRoom`
@ -169,3 +176,15 @@ export function getMediaQueryFromMCX(mxc?: string): string {
return ''; return '';
} }
} }
export async function getGroup(
clientURL: string,
groupId: string
): Promise<Group> {
try {
const resolvedClient = await client(clientURL);
return await getGroupDetails(resolvedClient, groupId);
} catch {
return fallbackGroup(groupId);
}
}